mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-25 18:45:27 -06:00
commit
0a67f71b94
2
.github/workflows/ci-db-tests.yml
vendored
2
.github/workflows/ci-db-tests.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.3', '8.4']
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
|
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.3', '8.4']
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3']
|
||||
command: ['cs', 'stan', 'swagger:validate']
|
||||
command: ['cs', 'stan', 'openapi:validate']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
|
@ -1,4 +1,4 @@
|
||||
name: Publish swagger spec
|
||||
name: Publish openapi spec
|
||||
|
||||
on:
|
||||
push:
|
||||
@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
php-version: ['8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Determine version
|
||||
@ -20,10 +20,10 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer swagger:inline
|
||||
extensions-cache-key: publish-openapi-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer openapi:inline
|
||||
- run: mkdir ${{ steps.determine_version.outputs.version }}
|
||||
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
|
||||
- run: mv docs/swagger/openapi-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
|
||||
- name: Publish spec
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -10,7 +10,6 @@ data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
data/GeoLite2-City.*
|
||||
data/infra/matomo
|
||||
docs/swagger-ui*
|
||||
docs/mercure.html
|
||||
.phpunit.result.cache
|
||||
docs/swagger/swagger-inlined.json
|
||||
docs/swagger/openapi-inlined.json
|
||||
|
36
CHANGELOG.md
36
CHANGELOG.md
@ -4,6 +4,42 @@ 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).
|
||||
|
||||
# [4.4.0] - 2024-12-27
|
||||
### Added
|
||||
* [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
|
||||
|
||||
* `default`: Short URLs only match if the path matches their short code or custom slug.
|
||||
* `append`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is appended to the long URL before redirecting.
|
||||
* `ignore`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is ignored.
|
||||
|
||||
This option effectively replaces the old `REDIRECT_APPEND_EXTRA_PATH` option, which is now deprecated and will be removed in Shlink 5.0.0
|
||||
|
||||
* [#2156](https://github.com/shlinkio/shlink/issues/2156) Be less restrictive on what characters are disallowed in custom slugs.
|
||||
|
||||
All [URI-reserved characters](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) were disallowed up until now, but from now on, only the gen-delimiters are.
|
||||
|
||||
* [#2229](https://github.com/shlinkio/shlink/issues/2229) Add `logo=disabled` query param to dynamically disable the default logo on QR codes.
|
||||
* [#2206](https://github.com/shlinkio/shlink/issues/2206) Add new `DB_USE_ENCRYPTION` config option to enable SSL database connections trusting any server certificate.
|
||||
* [#2209](https://github.com/shlinkio/shlink/issues/2209) Redirect rules are now imported when importing short URLs from a Shlink >=4.0 instance.
|
||||
|
||||
### Changed
|
||||
* [#2281](https://github.com/shlinkio/shlink/issues/2281) Update docker image to PHP 8.4
|
||||
* [#2124](https://github.com/shlinkio/shlink/issues/2124) Improve how Shlink decides if a GeoLite db file needs to be downloaded, and reduces the chances for API limits to be reached.
|
||||
|
||||
Now Shlink tracks all download attempts, and knows which of them failed and succeeded. This lets it know when was the last error or success, how many consecutive errors have happened, etc.
|
||||
|
||||
It also tracks now the reason for a download to be attempted, and the error that happened when one fails.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#2247](https://github.com/shlinkio/shlink/issues/2247) Drop support for PHP 8.2
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
# [4.3.1] - 2024-11-25
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM php:8.3-alpine3.20 AS base
|
||||
FROM php:8.4-alpine3.21 AS base
|
||||
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
@ -36,7 +36,7 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
|
||||
apk del .phpize-deps
|
||||
|
||||
# Install shlink
|
||||
FROM base as builder
|
||||
FROM base AS builder
|
||||
COPY . .
|
||||
COPY --from=composer:2 /usr/bin/composer ./composer.phar
|
||||
RUN apk add --no-cache git && \
|
||||
|
@ -36,7 +36,7 @@ The idea is that you can just generate a container using the image and provide t
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 8.2 or 8.3
|
||||
* PHP 8.3 or 8.4
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
|
||||
* apcu extension is recommended if you don't plan to use RoadRunner.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
|
@ -12,56 +12,56 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"ext-curl": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.4",
|
||||
"akrabat/ip-address-middleware": "^2.5",
|
||||
"cakephp/chronos": "^3.1",
|
||||
"doctrine/dbal": "^4.2",
|
||||
"doctrine/migrations": "^3.8",
|
||||
"doctrine/orm": "^3.3",
|
||||
"donatj/phpuseragentparser": "^1.10",
|
||||
"endroid/qr-code": "^6.0",
|
||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||
"geoip2/geoip2": "^3.0",
|
||||
"geoip2/geoip2": "^3.1",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"hidehalo/nanoid-php": "^2.0",
|
||||
"jaybizzle/crawler-detect": "^1.3",
|
||||
"laminas/laminas-config-aggregator": "^1.15",
|
||||
"laminas/laminas-config-aggregator": "^1.17",
|
||||
"laminas/laminas-diactoros": "^3.5",
|
||||
"laminas/laminas-inputfilter": "^2.30",
|
||||
"laminas/laminas-servicemanager": "^3.22",
|
||||
"laminas/laminas-stdlib": "^3.19",
|
||||
"laminas/laminas-inputfilter": "^2.31",
|
||||
"laminas/laminas-servicemanager": "^3.23",
|
||||
"laminas/laminas-stdlib": "^3.20",
|
||||
"matomo/matomo-php-tracker": "^3.3",
|
||||
"mezzio/mezzio": "^3.20",
|
||||
"mezzio/mezzio-fastroute": "^3.12",
|
||||
"mezzio/mezzio-problem-details": "^1.15",
|
||||
"mlocati/ip-lib": "^1.18.1",
|
||||
"mobiledetect/mobiledetectlib": "4.8.x-dev#920c549 as 4.9",
|
||||
"pagerfanta/core": "^3.8",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.1.1",
|
||||
"shlinkio/doctrine-specification": "^2.2",
|
||||
"shlinkio/shlink-common": "^6.6",
|
||||
"shlinkio/shlink-config": "^3.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.1",
|
||||
"shlinkio/shlink-importer": "^5.3.2",
|
||||
"shlinkio/shlink-installer": "^9.3",
|
||||
"shlinkio/shlink-importer": "^5.5",
|
||||
"shlinkio/shlink-installer": "^9.4",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.2",
|
||||
"shlinkio/shlink-json": "^1.1",
|
||||
"spiral/roadrunner": "^2024.1",
|
||||
"shlinkio/shlink-json": "^1.2",
|
||||
"spiral/roadrunner": "^2024.3",
|
||||
"spiral/roadrunner-cli": "^2.6",
|
||||
"spiral/roadrunner-http": "^3.5",
|
||||
"spiral/roadrunner-jobs": "^4.5",
|
||||
"symfony/console": "^7.1",
|
||||
"symfony/filesystem": "^7.1",
|
||||
"symfony/lock": "^7.1",
|
||||
"symfony/process": "^7.1",
|
||||
"symfony/string": "^7.1"
|
||||
"spiral/roadrunner-jobs": "^4.6",
|
||||
"symfony/console": "^7.2",
|
||||
"symfony/filesystem": "^7.2",
|
||||
"symfony/lock": "^7.2",
|
||||
"symfony/process": "^7.2",
|
||||
"symfony/string": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"devizzent/cebe-php-openapi": "^1.0.1",
|
||||
"devizzent/cebe-php-openapi": "^1.1.2",
|
||||
"devster/ubench": "^2.1",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
@ -69,11 +69,11 @@
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/php-code-coverage": "^11.0",
|
||||
"phpunit/phpcov": "^10.0",
|
||||
"phpunit/phpunit": "^11.4",
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.4.0",
|
||||
"shlinkio/shlink-test-utils": "^4.2",
|
||||
"symfony/var-dumper": "^7.1",
|
||||
"symfony/var-dumper": "^7.2",
|
||||
"veewee/composer-run-parallel": "^1.4"
|
||||
},
|
||||
"conflict": {
|
||||
@ -108,7 +108,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"ci": [
|
||||
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
|
||||
"@parallel cs stan openapi:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
|
||||
"@parallel test:api:ci test:cli:ci"
|
||||
],
|
||||
"cs": "phpcs -s",
|
||||
@ -154,36 +154,18 @@
|
||||
"@test:cli",
|
||||
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
|
||||
],
|
||||
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
|
||||
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json",
|
||||
"openapi:validate": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi validate docs/swagger/swagger.json",
|
||||
"openapi:inline": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
|
||||
"swagger:validate": [
|
||||
"echo \"This command is deprecated. Use openapi:validate instead\"",
|
||||
"@openapi:validate"
|
||||
],
|
||||
"swagger:inline": [
|
||||
"echo \"This command is deprecated. Use openapi:inline instead\"",
|
||||
"@openapi:inline"
|
||||
],
|
||||
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"swagger:validate\" and \"test:ci\"</>",
|
||||
"cs": "<fg=blue;options=bold>Checks coding styles</>",
|
||||
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
|
||||
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
|
||||
"test": "<fg=blue;options=bold>Runs all test suites</>",
|
||||
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
|
||||
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
|
||||
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
|
||||
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
|
||||
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
|
||||
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
||||
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
|
||||
"test:db:ms": "<fg=blue;options=bold>Runs database test suites on a Microsoft SQL Server database</>",
|
||||
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
|
||||
"test:api:ci": "<fg=blue;options=bold>Runs API test suites, and generates code coverage for CI</>",
|
||||
"test:api:pretty": "<fg=blue;options=bold>Runs API test suites, and generates code coverage in HTML format</>",
|
||||
"test:cli": "<fg=blue;options=bold>Runs CLI test suites</>",
|
||||
"test:cli:ci": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage for CI</>",
|
||||
"test:cli:pretty": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage in HTML format</>",
|
||||
"swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>",
|
||||
"swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>",
|
||||
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"platform-check": false,
|
||||
|
@ -12,9 +12,10 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
|
||||
return (static function (): array {
|
||||
$driver = EnvVars::DB_DRIVER->loadFromEnv();
|
||||
$useEncryption = (bool) EnvVars::DB_USE_ENCRYPTION->loadFromEnv();
|
||||
$isMysqlCompatible = contains($driver, ['maria', 'mysql']);
|
||||
|
||||
$resolveDriver = static fn () => match ($driver) {
|
||||
$doctrineDriver = match ($driver) {
|
||||
'postgres' => 'pdo_pgsql',
|
||||
'mssql' => 'pdo_sqlsrv',
|
||||
default => 'pdo_mysql',
|
||||
@ -23,31 +24,40 @@ return (static function (): array {
|
||||
$value = $envVar->loadFromEnv();
|
||||
return $value === null ? null : (string) $value;
|
||||
};
|
||||
$resolveCharset = static fn () => match ($driver) {
|
||||
$charset = match ($driver) {
|
||||
// This does not determine charsets or collations in tables or columns, but the charset used in the data
|
||||
// flowing in the connection, so it has to match what has been set in the database.
|
||||
'maria', 'mysql' => 'utf8mb4',
|
||||
'postgres' => 'utf8',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$resolveConnection = static fn () => match ($driver) {
|
||||
$driverOptions = match ($driver) {
|
||||
'mssql' => ['TrustServerCertificate' => 'true'],
|
||||
'maria', 'mysql' => ! $useEncryption ? [] : [
|
||||
1007 => true, // PDO::MYSQL_ATTR_SSL_KEY: Require using SSL
|
||||
1014 => false, // PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: Trust any certificate
|
||||
],
|
||||
'postgres' => ! $useEncryption ? [] : [
|
||||
'sslmode' => 'require', // Require connections to be encrypted
|
||||
'sslrootcert' => '', // Allow any certificate
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
$connection = match ($driver) {
|
||||
null, 'sqlite' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => 'data/database.sqlite',
|
||||
],
|
||||
default => [
|
||||
'driver' => $resolveDriver(),
|
||||
'driver' => $doctrineDriver,
|
||||
'dbname' => EnvVars::DB_NAME->loadFromEnv(),
|
||||
'user' => $readCredentialAsString(EnvVars::DB_USER),
|
||||
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD),
|
||||
'host' => EnvVars::DB_HOST->loadFromEnv(),
|
||||
'port' => EnvVars::DB_PORT->loadFromEnv(),
|
||||
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
|
||||
'charset' => $resolveCharset(),
|
||||
'driverOptions' => $driver !== 'mssql' ? [] : [
|
||||
'TrustServerCertificate' => 'true',
|
||||
],
|
||||
'charset' => $charset,
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
};
|
||||
|
||||
@ -63,7 +73,7 @@ return (static function (): array {
|
||||
Events::postFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
|
||||
],
|
||||
],
|
||||
'connection' => $resolveConnection(),
|
||||
'connection' => $connection,
|
||||
],
|
||||
|
||||
];
|
||||
|
@ -20,6 +20,7 @@ return [
|
||||
Option\Database\DatabaseUserConfigOption::class,
|
||||
Option\Database\DatabasePasswordConfigOption::class,
|
||||
Option\Database\DatabaseUnixSocketConfigOption::class,
|
||||
Option\Database\DatabaseUseEncryptionConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainHostConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
@ -41,7 +42,7 @@ return [
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||
Option\UrlShortener\ExtraPathModeConfigOption::class,
|
||||
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
||||
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
||||
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM php:8.3-fpm-alpine3.20
|
||||
FROM php:8.4-fpm-alpine3.21
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.24
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM php:8.3-alpine3.20
|
||||
FROM php:8.4-alpine3.21
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV PDO_SQLSRV_VERSION 5.12.0
|
||||
|
@ -85,6 +85,16 @@
|
||||
"type": "string",
|
||||
"default": "#ffffff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logo",
|
||||
"in": "query",
|
||||
"description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["disable"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\CLI;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainService;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Core\Matomo;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
|
||||
use Shlinkio\Shlink\Core\ShortUrl;
|
||||
@ -17,15 +17,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2ReaderFactory;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Symfony\Component\Console as SymfonyCli;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
@ -34,7 +30,6 @@ return [
|
||||
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
|
||||
PhpExecutableFinder::class => InvokableFactory::class,
|
||||
|
||||
GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
|
||||
RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
|
||||
Util\ProcessRunner::class => ConfigAbstractFactory::class,
|
||||
|
||||
@ -82,12 +77,6 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
GeoLite\GeolocationDbUpdater::class => [
|
||||
DbUpdater::class,
|
||||
GeoLite2ReaderFactory::class,
|
||||
LOCAL_LOCK_FACTORY,
|
||||
TrackingOptions::class,
|
||||
],
|
||||
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
|
||||
ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class],
|
||||
|
||||
@ -107,7 +96,7 @@ return [
|
||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
|
||||
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [GeolocationDbUpdater::class],
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
Visit\Geolocation\VisitLocator::class,
|
||||
Visit\Geolocation\VisitToLocationHelper::class,
|
||||
|
@ -20,7 +20,7 @@ use function sprintf;
|
||||
|
||||
class DisableKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:disable';
|
||||
public const string NAME = 'api-key:disable';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
|
@ -23,7 +23,7 @@ use function sprintf;
|
||||
|
||||
class GenerateKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:generate';
|
||||
public const string NAME = 'api-key:generate';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiKeyServiceInterface $apiKeyService,
|
||||
|
@ -13,7 +13,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class InitialApiKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:initial';
|
||||
public const string NAME = 'api-key:initial';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
|
@ -21,11 +21,11 @@ use function sprintf;
|
||||
|
||||
class ListKeysCommand extends Command
|
||||
{
|
||||
private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
|
||||
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
|
||||
private const WARNING_STRING_PATTERN = '<comment>%s</comment>';
|
||||
private const string ERROR_STRING_PATTERN = '<fg=red>%s</>';
|
||||
private const string SUCCESS_STRING_PATTERN = '<info>%s</info>';
|
||||
private const string WARNING_STRING_PATTERN = '<comment>%s</comment>';
|
||||
|
||||
public const NAME = 'api-key:list';
|
||||
public const string NAME = 'api-key:list';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
|
@ -19,7 +19,7 @@ use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
|
||||
class RenameApiKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:rename';
|
||||
public const string NAME = 'api-key:rename';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
|
@ -21,7 +21,7 @@ use function sprintf;
|
||||
|
||||
class ReadEnvVarCommand extends Command
|
||||
{
|
||||
public const NAME = 'env-var:read';
|
||||
public const string NAME = 'env-var:read';
|
||||
|
||||
/** @var Closure(string $envVar): mixed */
|
||||
private readonly Closure $loadEnvVar;
|
||||
|
@ -24,9 +24,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
{
|
||||
private readonly Connection $regularConn;
|
||||
|
||||
public const NAME = 'db:create';
|
||||
public const DOCTRINE_SCRIPT = 'bin/doctrine';
|
||||
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||
public const string NAME = 'db:create';
|
||||
public const string DOCTRINE_SCRIPT = 'bin/doctrine';
|
||||
public const string DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
|
@ -11,9 +11,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class MigrateDatabaseCommand extends AbstractDatabaseCommand
|
||||
{
|
||||
public const NAME = 'db:migrate';
|
||||
public const DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
|
||||
public const DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
|
||||
public const string NAME = 'db:migrate';
|
||||
public const string DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
|
||||
public const string DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
|
@ -21,7 +21,7 @@ use function str_contains;
|
||||
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:redirects';
|
||||
public const string NAME = 'domain:redirects';
|
||||
|
||||
public function __construct(private readonly DomainServiceInterface $domainService)
|
||||
{
|
||||
|
@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'domain:visits';
|
||||
public const string NAME = 'domain:visits';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
|
@ -18,7 +18,7 @@ use function array_map;
|
||||
|
||||
class ListDomainsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:list';
|
||||
public const string NAME = 'domain:list';
|
||||
|
||||
public function __construct(private readonly DomainServiceInterface $domainService)
|
||||
{
|
||||
|
@ -22,7 +22,7 @@ use function sprintf;
|
||||
|
||||
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
|
||||
{
|
||||
public const NAME = 'integration:matomo:send-visits';
|
||||
public const string NAME = 'integration:matomo:send-visits';
|
||||
|
||||
private readonly bool $matomoEnabled;
|
||||
private SymfonyStyle $io;
|
||||
|
@ -19,7 +19,7 @@ use function sprintf;
|
||||
|
||||
class ManageRedirectRulesCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:manage-rules';
|
||||
public const string NAME = 'short-url:manage-rules';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
|
@ -20,7 +20,7 @@ use function sprintf;
|
||||
|
||||
class CreateShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:create';
|
||||
public const string NAME = 'short-url:create';
|
||||
|
||||
private SymfonyStyle $io;
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
|
@ -17,7 +17,7 @@ use function sprintf;
|
||||
|
||||
class DeleteExpiredShortUrlsCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:delete-expired';
|
||||
public const string NAME = 'short-url:delete-expired';
|
||||
|
||||
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
|
@ -19,7 +19,7 @@ use function sprintf;
|
||||
|
||||
class DeleteShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:delete';
|
||||
public const string NAME = 'short-url:delete';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
|
@ -16,7 +16,7 @@ use function sprintf;
|
||||
|
||||
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits-delete';
|
||||
public const string NAME = 'short-url:visits-delete';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
|
@ -19,7 +19,7 @@ use function sprintf;
|
||||
|
||||
class EditShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:edit';
|
||||
public const string NAME = 'short-url:edit';
|
||||
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
@ -16,7 +16,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
public const string NAME = 'short-url:visits';
|
||||
|
||||
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
|
@ -33,7 +33,7 @@ use function sprintf;
|
||||
|
||||
class ListShortUrlsCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:list';
|
||||
public const string NAME = 'short-url:list';
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
|
@ -17,7 +17,7 @@ use function sprintf;
|
||||
|
||||
class ResolveUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:parse';
|
||||
public const string NAME = 'short-url:parse';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
|
@ -14,9 +14,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DeleteTagsCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:delete';
|
||||
public const string NAME = 'tag:delete';
|
||||
|
||||
public function __construct(private TagServiceInterface $tagService)
|
||||
public function __construct(private readonly TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'tag:visits';
|
||||
public const string NAME = 'tag:visits';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
|
@ -17,7 +17,7 @@ use function array_map;
|
||||
|
||||
class ListTagsCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:list';
|
||||
public const string NAME = 'tag:list';
|
||||
|
||||
public function __construct(private readonly TagServiceInterface $tagService)
|
||||
{
|
||||
|
@ -17,7 +17,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class RenameTagCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:rename';
|
||||
public const string NAME = 'tag:rename';
|
||||
|
||||
public function __construct(private readonly TagServiceInterface $tagService)
|
||||
{
|
||||
|
@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||
|
||||
final class LockedCommandConfig
|
||||
{
|
||||
public const DEFAULT_TTL = 600.0; // 10 minutes
|
||||
public const float DEFAULT_TTL = 600.0; // 10 minutes
|
||||
|
||||
private function __construct(
|
||||
public readonly string $lockName,
|
||||
|
@ -13,7 +13,7 @@ use function sprintf;
|
||||
|
||||
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
|
||||
{
|
||||
public const NAME = 'visit:orphan-delete';
|
||||
public const string NAME = 'visit:orphan-delete';
|
||||
|
||||
public function __construct(private readonly VisitsDeleterInterface $deleter)
|
||||
{
|
||||
|
@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@ -16,13 +17,14 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DownloadGeoLiteDbCommand extends Command
|
||||
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
|
||||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
public const string NAME = 'visit:download-db';
|
||||
|
||||
private ProgressBar|null $progressBar = null;
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||
public function __construct(private readonly GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@ -39,38 +41,42 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$result = $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
|
||||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
});
|
||||
$result = $this->dbUpdater->checkDbUpdate($this);
|
||||
|
||||
if ($result === GeolocationResult::LICENSE_MISSING) {
|
||||
$io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
|
||||
$this->io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
if ($result === GeolocationResult::MAX_ERRORS_REACHED) {
|
||||
$this->io->warning('Max consecutive errors reached. Cannot retry for a couple of days.');
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
if ($result === GeolocationResult::UPDATE_IN_PROGRESS) {
|
||||
$this->io->warning('A geolocation db is already being downloaded by another process.');
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
if ($this->progressBar === null) {
|
||||
$io->info('GeoLite2 db file is up to date.');
|
||||
$this->io->info('GeoLite2 db file is up to date.');
|
||||
} else {
|
||||
$this->progressBar->finish();
|
||||
$io->success('GeoLite2 db file properly downloaded.');
|
||||
$this->io->success('GeoLite2 db file properly downloaded.');
|
||||
}
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
return $this->processGeoLiteUpdateError($e, $io);
|
||||
return $this->processGeoLiteUpdateError($e, $this->io);
|
||||
}
|
||||
}
|
||||
|
||||
private function processGeoLiteUpdateError(GeolocationDbUpdateFailedException $e, SymfonyStyle $io): int
|
||||
{
|
||||
$olderDbExists = $e->olderDbExists();
|
||||
$olderDbExists = $e->olderDbExists;
|
||||
|
||||
if ($olderDbExists) {
|
||||
$io->warning(
|
||||
@ -86,4 +92,16 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
|
||||
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
|
||||
public function beforeDownload(bool $olderDbExists): void
|
||||
{
|
||||
$this->io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($this->io);
|
||||
}
|
||||
|
||||
public function handleProgress(int $total, int $downloaded, bool $olderDbExists): void
|
||||
{
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'visit:non-orphan';
|
||||
public const string NAME = 'visit:non-orphan';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
|
@ -17,7 +17,7 @@ use function sprintf;
|
||||
|
||||
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'visit:orphan';
|
||||
public const string NAME = 'visit:orphan';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
|
@ -29,7 +29,7 @@ use function sprintf;
|
||||
|
||||
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
|
||||
{
|
||||
public const NAME = 'visit:locate';
|
||||
public const string NAME = 'visit:locate';
|
||||
|
||||
private SymfonyStyle $io;
|
||||
|
||||
|
@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
private bool $olderDbExists;
|
||||
|
||||
private function __construct(string $message, Throwable|null $previous = null)
|
||||
{
|
||||
parent::__construct($message, previous: $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
$prev,
|
||||
);
|
||||
$e->olderDbExists = true;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function withoutOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
$prev,
|
||||
);
|
||||
$e->olderDbExists = false;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function withInvalidEpochInOldDb(mixed $buildEpoch): self
|
||||
{
|
||||
$e = new self(sprintf(
|
||||
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
|
||||
$buildEpoch,
|
||||
));
|
||||
$e->olderDbExists = true;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public function olderDbExists(): bool
|
||||
{
|
||||
return $this->olderDbExists;
|
||||
}
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\GeoLite;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Closure;
|
||||
use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
|
||||
use function is_int;
|
||||
|
||||
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
{
|
||||
private const LOCK_NAME = 'geolocation-db-update';
|
||||
|
||||
/** @var Closure(): Reader */
|
||||
private readonly Closure $geoLiteDbReaderFactory;
|
||||
|
||||
/**
|
||||
* @param callable(): Reader $geoLiteDbReaderFactory
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DbUpdaterInterface $dbUpdater,
|
||||
callable $geoLiteDbReaderFactory,
|
||||
private readonly LockFactory $locker,
|
||||
private readonly TrackingOptions $trackingOptions,
|
||||
) {
|
||||
$this->geoLiteDbReaderFactory = $geoLiteDbReaderFactory(...);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(
|
||||
callable|null $beforeDownload = null,
|
||||
callable|null $handleProgress = null,
|
||||
): GeolocationResult {
|
||||
if (! $this->trackingOptions->isGeolocationRelevant()) {
|
||||
return GeolocationResult::CHECK_SKIPPED;
|
||||
}
|
||||
|
||||
$lock = $this->locker->createLock(self::LOCK_NAME);
|
||||
$lock->acquire(true); // Block until lock is released
|
||||
|
||||
try {
|
||||
return $this->downloadIfNeeded($beforeDownload, $handleProgress);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadIfNeeded(callable|null $beforeDownload, callable|null $handleProgress): GeolocationResult
|
||||
{
|
||||
if (! $this->dbUpdater->databaseFileExists()) {
|
||||
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
|
||||
}
|
||||
|
||||
$meta = ($this->geoLiteDbReaderFactory)()->metadata();
|
||||
if ($this->buildIsTooOld($meta)) {
|
||||
return $this->downloadNewDb(true, $beforeDownload, $handleProgress);
|
||||
}
|
||||
|
||||
return GeolocationResult::DB_IS_UP_TO_DATE;
|
||||
}
|
||||
|
||||
private function buildIsTooOld(Metadata $meta): bool
|
||||
{
|
||||
$buildTimestamp = $this->resolveBuildTimestamp($meta);
|
||||
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
|
||||
|
||||
return Chronos::now()->greaterThan($buildDate->addDays(35));
|
||||
}
|
||||
|
||||
private function resolveBuildTimestamp(Metadata $meta): int
|
||||
{
|
||||
// In theory the buildEpoch should be an int, but it has been reported to come as a string.
|
||||
// See https://github.com/shlinkio/shlink/issues/1002 for context
|
||||
|
||||
/** @var int|string $buildEpoch */
|
||||
$buildEpoch = $meta->buildEpoch;
|
||||
if (is_int($buildEpoch)) {
|
||||
return $buildEpoch;
|
||||
}
|
||||
|
||||
$intBuildEpoch = (int) $buildEpoch;
|
||||
if ($buildEpoch === (string) $intBuildEpoch) {
|
||||
return $intBuildEpoch;
|
||||
}
|
||||
|
||||
throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadNewDb(
|
||||
bool $olderDbExists,
|
||||
callable|null $beforeDownload,
|
||||
callable|null $handleProgress,
|
||||
): GeolocationResult {
|
||||
if ($beforeDownload !== null) {
|
||||
$beforeDownload($olderDbExists);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
|
||||
return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED;
|
||||
} catch (MissingLicenseException) {
|
||||
return GeolocationResult::LICENSE_MISSING;
|
||||
} catch (DbUpdateException | WrongIpException $e) {
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb($e)
|
||||
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function wrapHandleProgressCallback(callable|null $handleProgress, bool $olderDbExists): callable|null
|
||||
{
|
||||
if ($handleProgress === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\GeoLite;
|
||||
|
||||
enum GeolocationResult
|
||||
{
|
||||
case CHECK_SKIPPED;
|
||||
case LICENSE_MISSING;
|
||||
case DB_CREATED;
|
||||
case DB_UPDATED;
|
||||
case DB_IS_UP_TO_DATE;
|
||||
}
|
@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
final class ExitCode
|
||||
{
|
||||
public const EXIT_SUCCESS = 0;
|
||||
public const EXIT_FAILURE = -1;
|
||||
public const EXIT_WARNING = 1;
|
||||
public const int EXIT_SUCCESS = 0;
|
||||
public const int EXIT_FAILURE = -1;
|
||||
public const int EXIT_WARNING = 1;
|
||||
}
|
||||
|
@ -12,8 +12,8 @@ use function array_pop;
|
||||
|
||||
final class ShlinkTable
|
||||
{
|
||||
private const DEFAULT_STYLE_NAME = 'default';
|
||||
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||
private const string DEFAULT_STYLE_NAME = 'default';
|
||||
private const string TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||
|
||||
private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators = false)
|
||||
{
|
||||
|
@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
|
||||
$this->service->expects($this->exactly($expectedDeleteCalls))->method('deleteByShortCode')->with(
|
||||
$identifier,
|
||||
$this->isType('bool'),
|
||||
$this->isBool(),
|
||||
)->willReturnCallback(function ($_, bool $ignoreThreshold) use ($shortCode): void {
|
||||
if (!$ignoreThreshold) {
|
||||
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
|
||||
|
@ -6,13 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@ -36,9 +38,9 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
int $expectedExitCode,
|
||||
): void {
|
||||
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback(
|
||||
function (callable $beforeDownload, callable $handleProgress) use ($olderDbExists): void {
|
||||
$beforeDownload($olderDbExists);
|
||||
$handleProgress(100, 50);
|
||||
function (GeolocationDownloadProgressHandlerInterface $handler) use ($olderDbExists): void {
|
||||
$handler->beforeDownload($olderDbExists);
|
||||
$handler->handleProgress(100, 50, $olderDbExists);
|
||||
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb()
|
||||
@ -73,17 +75,18 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsPrintedWhenLicenseIsMissing(): void
|
||||
#[TestWith([GeolocationResult::LICENSE_MISSING, 'It was not possible to download GeoLite2 db'])]
|
||||
#[TestWith([GeolocationResult::MAX_ERRORS_REACHED, 'Max consecutive errors reached'])]
|
||||
#[TestWith([GeolocationResult::UPDATE_IN_PROGRESS, 'A geolocation db is already being downloaded'])]
|
||||
public function warningIsPrintedForSomeResults(GeolocationResult $result, string $expectedWarningMessage): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn(
|
||||
GeolocationResult::LICENSE_MISSING,
|
||||
);
|
||||
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn($result);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString('[WARNING] It was not possible to download GeoLite2 db', $output);
|
||||
self::assertStringContainsString('[WARNING] ' . $expectedWarningMessage, $output);
|
||||
self::assertSame(ExitCode::EXIT_WARNING, $exitCode);
|
||||
}
|
||||
|
||||
@ -105,8 +108,8 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
public static function provideSuccessParams(): iterable
|
||||
{
|
||||
yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.'];
|
||||
yield 'outdated db' => [function (callable $beforeDownload): GeolocationResult {
|
||||
$beforeDownload(true);
|
||||
yield 'outdated db' => [function (GeolocationDownloadProgressHandlerInterface $handler): GeolocationResult {
|
||||
$handler->beforeDownload(true);
|
||||
return GeolocationResult::DB_CREATED;
|
||||
}, '[OK] GeoLite2 db file properly downloaded.'];
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
|
||||
$locker = $this->createMock(Lock\LockFactory::class);
|
||||
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
|
||||
$locker->method('createLock')->with($this->isType('string'), 600.0, false)->willReturn($this->lock);
|
||||
$locker->method('createLock')->with($this->isString(), 600.0, false)->willReturn($this->lock);
|
||||
|
||||
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
|
||||
|
||||
|
@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Throwable;
|
||||
|
||||
class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
@ -19,7 +19,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withOlderDb($prev);
|
||||
|
||||
self::assertTrue($e->olderDbExists());
|
||||
self::assertTrue($e->olderDbExists);
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
$e->getMessage(),
|
||||
@ -33,7 +33,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
|
||||
|
||||
self::assertFalse($e->olderDbExists());
|
||||
self::assertFalse($e->olderDbExists);
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
$e->getMessage(),
|
||||
@ -48,16 +48,4 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
yield 'RuntimeException' => [new RuntimeException('prev')];
|
||||
yield 'Exception' => [new Exception('prev')];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function withInvalidEpochInOldDbBuildsException(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar');
|
||||
|
||||
self::assertTrue($e->olderDbExists());
|
||||
self::assertEquals(
|
||||
'Build epoch with value "foobar" from existing geolocation database, could not be parsed to integer.',
|
||||
$e->getMessage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\GeoLite;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock;
|
||||
use Throwable;
|
||||
|
||||
use function array_map;
|
||||
use function range;
|
||||
|
||||
class GeolocationDbUpdaterTest extends TestCase
|
||||
{
|
||||
private MockObject & DbUpdaterInterface $dbUpdater;
|
||||
private MockObject & Reader $geoLiteDbReader;
|
||||
private MockObject & Lock\LockInterface $lock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dbUpdater = $this->createMock(DbUpdaterInterface::class);
|
||||
$this->geoLiteDbReader = $this->createMock(Reader::class);
|
||||
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
|
||||
$this->lock->method('acquire')->with($this->isTrue())->willReturn(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function properResultIsReturnedWhenLicenseIsMissing(): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException(
|
||||
new MissingLicenseException(''),
|
||||
);
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
$isCalled = false;
|
||||
$result = $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
|
||||
$isCalled = true;
|
||||
});
|
||||
|
||||
self::assertTrue($isCalled);
|
||||
self::assertEquals(GeolocationResult::LICENSE_MISSING, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
|
||||
{
|
||||
$prev = new DbUpdateException('');
|
||||
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with(
|
||||
$this->isNull(),
|
||||
)->willThrowException($prev);
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
$isCalled = false;
|
||||
try {
|
||||
$this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
|
||||
$isCalled = true;
|
||||
});
|
||||
self::fail();
|
||||
} catch (Throwable $e) {
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertFalse($e->olderDbExists());
|
||||
self::assertTrue($isCalled);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideBigDays')]
|
||||
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
|
||||
{
|
||||
$prev = new DbUpdateException('');
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with(
|
||||
$this->isNull(),
|
||||
)->willThrowException($prev);
|
||||
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
|
||||
$this->buildMetaWithBuildEpoch(Chronos::now()->subDays($days)->getTimestamp()),
|
||||
);
|
||||
|
||||
try {
|
||||
$this->geolocationDbUpdater()->checkDbUpdate();
|
||||
self::fail();
|
||||
} catch (Throwable $e) {
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertTrue($e->olderDbExists());
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideBigDays(): iterable
|
||||
{
|
||||
yield [36];
|
||||
yield [50];
|
||||
yield [75];
|
||||
yield [100];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideSmallDays')]
|
||||
public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
|
||||
$this->dbUpdater->expects($this->never())->method('downloadFreshCopy');
|
||||
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
|
||||
$this->buildMetaWithBuildEpoch($buildEpoch),
|
||||
);
|
||||
|
||||
$result = $this->geolocationDbUpdater()->checkDbUpdate();
|
||||
|
||||
self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result);
|
||||
}
|
||||
|
||||
public static function provideSmallDays(): iterable
|
||||
{
|
||||
$generateParamsWithTimestamp = static function (int $days) {
|
||||
$timestamp = Chronos::now()->subDays($days)->getTimestamp();
|
||||
return [$days % 2 === 0 ? $timestamp : (string) $timestamp];
|
||||
};
|
||||
|
||||
return array_map($generateParamsWithTimestamp, range(0, 34));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
|
||||
$this->dbUpdater->expects($this->never())->method('downloadFreshCopy');
|
||||
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
|
||||
$this->buildMetaWithBuildEpoch('invalid'),
|
||||
);
|
||||
|
||||
$this->expectException(GeolocationDbUpdateFailedException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.',
|
||||
);
|
||||
|
||||
$this->geolocationDbUpdater()->checkDbUpdate();
|
||||
}
|
||||
|
||||
private function buildMetaWithBuildEpoch(string|int $buildEpoch): Metadata
|
||||
{
|
||||
return new Metadata([
|
||||
'binary_format_major_version' => '',
|
||||
'binary_format_minor_version' => '',
|
||||
'build_epoch' => $buildEpoch,
|
||||
'database_type' => '',
|
||||
'languages' => '',
|
||||
'description' => '',
|
||||
'ip_version' => '',
|
||||
'node_count' => 1,
|
||||
'record_size' => 4,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideTrackingOptions')]
|
||||
public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void
|
||||
{
|
||||
$result = $this->geolocationDbUpdater($options)->checkDbUpdate();
|
||||
$this->dbUpdater->expects($this->never())->method('databaseFileExists');
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result);
|
||||
}
|
||||
|
||||
public static function provideTrackingOptions(): iterable
|
||||
{
|
||||
yield 'disableTracking' => [new TrackingOptions(disableTracking: true)];
|
||||
yield 'disableIpTracking' => [new TrackingOptions(disableIpTracking: true)];
|
||||
yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)];
|
||||
}
|
||||
|
||||
private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater
|
||||
{
|
||||
$locker = $this->createMock(Lock\LockFactory::class);
|
||||
$locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock);
|
||||
|
||||
return new GeolocationDbUpdater(
|
||||
$this->dbUpdater,
|
||||
fn () => $this->geoLiteDbReader,
|
||||
$locker,
|
||||
$options ?? new TrackingOptions(),
|
||||
);
|
||||
}
|
||||
}
|
@ -77,7 +77,7 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
$this->io->expects($this->once())->method('choice')->willReturn($action->value);
|
||||
$this->io->expects($this->never())->method('newLine');
|
||||
$this->io->expects($this->never())->method('text');
|
||||
$this->io->expects($this->once())->method('table')->with($this->isType('array'), [
|
||||
$this->io->expects($this->once())->method('table')->with($this->isArray(), [
|
||||
['1', $comment($this->cond1->toHumanFriendly()), 'https://example.com/one'],
|
||||
[
|
||||
'2',
|
||||
|
@ -9,12 +9,15 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
|
||||
use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
@ -103,6 +106,7 @@ return [
|
||||
|
||||
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
|
||||
Geolocation\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
|
||||
Geolocation\Middleware\IpGeolocationMiddleware::class => ConfigAbstractFactory::class,
|
||||
|
||||
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
|
||||
@ -240,6 +244,12 @@ return [
|
||||
|
||||
EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class],
|
||||
|
||||
GeolocationDbUpdater::class => [
|
||||
DbUpdater::class,
|
||||
LOCAL_LOCK_FACTORY,
|
||||
Config\Options\TrackingOptions::class,
|
||||
'em',
|
||||
],
|
||||
Geolocation\Middleware\IpGeolocationMiddleware::class => [
|
||||
IpLocationResolverInterface::class,
|
||||
DbUpdater::class,
|
||||
@ -252,6 +262,7 @@ return [
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class,
|
||||
Util\DoctrineBatchHelper::class,
|
||||
RedirectRule\ShortUrlRedirectRuleService::class,
|
||||
],
|
||||
|
||||
Crawling\CrawlingHelper::class => [ShortUrl\Repository\CrawlableShortCodesQuery::class],
|
||||
|
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
use Shlinkio\Shlink\Core\Geolocation\Entity\GeolocationDbUpdateStatus;
|
||||
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable(determineTableName('geolocation_db_updates', $emConfig));
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('date_created')
|
||||
->build();
|
||||
|
||||
$builder->createField('dateUpdated', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('date_updated')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
(new FieldBuilder($builder, [
|
||||
'fieldName' => 'status',
|
||||
'type' => Types::STRING,
|
||||
'enumType' => GeolocationDbUpdateStatus::class,
|
||||
]))->columnName('status')
|
||||
->length(128)
|
||||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('error', Types::STRING), $emConfig)
|
||||
->columnName('error')
|
||||
->length(1024)
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('reason', Types::STRING), $emConfig)
|
||||
->columnName('reason')
|
||||
->length(1024)
|
||||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('filesystemId', Types::STRING), $emConfig)
|
||||
->columnName('filesystem_id')
|
||||
->length(512)
|
||||
->build();
|
||||
|
||||
// Index on date_updated, as we'll usually sort the query by this field
|
||||
$builder->addIndex(['date_updated'], 'IDX_geolocation_date_updated');
|
||||
// Index on filesystem_id, as we'll usually filter the query by this field
|
||||
$builder->addIndex(['filesystem_id'], 'IDX_geolocation_status_filesystem');
|
||||
};
|
@ -28,10 +28,14 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->length(2048)
|
||||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('shortCode', Types::STRING), $emConfig, 'bin')
|
||||
$shortCodeField = fieldWithUtf8Charset($builder->createField('shortCode', Types::STRING), $emConfig, 'bin')
|
||||
->columnName('short_code')
|
||||
->length(255)
|
||||
->build();
|
||||
->length(255);
|
||||
if (($emConfig['connection']['driver'] ?? null) === 'pdo_sqlsrv') {
|
||||
// Make sure a case-sensitive charset is set in short code for Microsoft SQL Server
|
||||
$shortCodeField->option('collation', 'Latin1_General_CS_AS');
|
||||
}
|
||||
$shortCodeField->build();
|
||||
|
||||
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('date_created')
|
||||
|
@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper;
|
||||
use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper;
|
||||
use Shlinkio\Shlink\Common\Mercure\MercureOptions;
|
||||
use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
|
||||
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
|
||||
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper;
|
||||
|
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20220110113313 extends AbstractMigration
|
||||
{
|
||||
private const CHARSET = 'utf8mb4';
|
||||
private const COLLATIONS = [
|
||||
'short_urls' => [
|
||||
'original_url' => 'unicode_ci',
|
||||
'short_code' => 'bin',
|
||||
'import_original_short_code' => 'unicode_ci',
|
||||
'title' => 'unicode_ci',
|
||||
],
|
||||
'domains' => [
|
||||
'authority' => 'unicode_ci',
|
||||
'base_url_redirect' => 'unicode_ci',
|
||||
'regular_not_found_redirect' => 'unicode_ci',
|
||||
'invalid_short_url_redirect' => 'unicode_ci',
|
||||
],
|
||||
'tags' => [
|
||||
'name' => 'unicode_ci',
|
||||
],
|
||||
'visits' => [
|
||||
'referer' => 'unicode_ci',
|
||||
'user_agent' => 'unicode_ci',
|
||||
'visited_url' => 'unicode_ci',
|
||||
],
|
||||
'visit_locations' => [
|
||||
'country_code' => 'unicode_ci',
|
||||
'country_name' => 'unicode_ci',
|
||||
'region_name' => 'unicode_ci',
|
||||
'city_name' => 'unicode_ci',
|
||||
'timezone' => 'unicode_ci',
|
||||
],
|
||||
];
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $this->isMySql(), 'This only sets MySQL-specific database options');
|
||||
|
||||
foreach (self::COLLATIONS as $tableName => $columns) {
|
||||
$table = $schema->getTable($tableName);
|
||||
|
||||
foreach ($columns as $columnName => $collation) {
|
||||
$table->getColumn($columnName)
|
||||
->setPlatformOption('charset', self::CHARSET)
|
||||
->setPlatformOption('collation', self::CHARSET . '_' . $collation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// No down
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! $this->isMySql();
|
||||
}
|
||||
|
||||
private function isMySql(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform() instanceof MySQLPlatform;
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230103105343 extends AbstractMigration
|
||||
{
|
||||
private const TABLE_NAME = 'device_long_urls';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf($schema->hasTable(self::TABLE_NAME));
|
||||
|
||||
$table = $schema->createTable(self::TABLE_NAME);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
|
||||
$table->addColumn('device_type', Types::STRING, ['length' => 255]);
|
||||
$table->addColumn('long_url', Types::STRING, ['length' => 2048]);
|
||||
$table->addColumn('short_url_id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
|
||||
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
|
||||
'onDelete' => 'CASCADE',
|
||||
'onUpdate' => 'RESTRICT',
|
||||
]);
|
||||
|
||||
$table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
|
||||
$schema->dropTable(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\SQLServerPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230130090946 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $this->isMsSql(), 'This only sets MsSQL-specific database options');
|
||||
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$shortCode = $shortUrls->getColumn('short_code');
|
||||
// Drop the unique index before changing the collation, as the field is part of this index
|
||||
$shortUrls->dropIndex('unique_short_code_plus_domain');
|
||||
$shortCode->setPlatformOption('collation', 'Latin1_General_CS_AS');
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
if ($this->isMsSql()) {
|
||||
// The index needs to be re-created in postUp, but here, we can only use statements run against the
|
||||
// connection directly
|
||||
$this->connection->executeStatement(
|
||||
'CREATE INDEX unique_short_code_plus_domain ON short_urls (domain_id, short_code);',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// No down
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
|
||||
private function isMsSql(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230211171904 extends AbstractMigration
|
||||
{
|
||||
private const INDEX_NAME = 'IDX_visits_potential_bot';
|
||||
private const string INDEX_NAME = 'IDX_visits_potential_bot';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
|
@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230303164233 extends AbstractMigration
|
||||
{
|
||||
private const INDEX_NAME = 'visits_potential_bot_IDX';
|
||||
private const string INDEX_NAME = 'visits_potential_bot_IDX';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
|
@ -17,10 +17,13 @@ use function in_array;
|
||||
*/
|
||||
final class Version20240220214031 extends AbstractMigration
|
||||
{
|
||||
private const DOMAINS_COLUMNS = ['base_url_redirect', 'regular_not_found_redirect', 'invalid_short_url_redirect'];
|
||||
private const TEXT_COLUMNS = [
|
||||
private const array DOMAINS_COLUMNS = [
|
||||
'base_url_redirect',
|
||||
'regular_not_found_redirect',
|
||||
'invalid_short_url_redirect',
|
||||
];
|
||||
private const array TEXT_COLUMNS = [
|
||||
'domains' => self::DOMAINS_COLUMNS,
|
||||
'device_long_urls' => ['long_url'],
|
||||
'short_urls' => ['original_url'],
|
||||
];
|
||||
|
||||
|
@ -11,7 +11,7 @@ use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20241124112257 extends AbstractMigration
|
||||
{
|
||||
private const COLUMN_NAME = 'redirect_url';
|
||||
private const string COLUMN_NAME = 'redirect_url';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
|
64
module/Core/migrations/Version20241212131058.php
Normal file
64
module/Core/migrations/Version20241212131058.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
|
||||
/**
|
||||
* Create a new table to track geolocation db updates
|
||||
*/
|
||||
final class Version20241212131058 extends AbstractMigration
|
||||
{
|
||||
private const string TABLE_NAME = 'geolocation_db_updates';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf($schema->hasTable(self::TABLE_NAME));
|
||||
|
||||
$table = $schema->createTable(self::TABLE_NAME);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
|
||||
$table->addColumn('date_created', ChronosDateTimeType::CHRONOS_DATETIME, ['default' => 'CURRENT_TIMESTAMP']);
|
||||
$table->addColumn('date_updated', ChronosDateTimeType::CHRONOS_DATETIME, ['default' => 'CURRENT_TIMESTAMP']);
|
||||
|
||||
$table->addColumn('status', Types::STRING, [
|
||||
'length' => 128,
|
||||
'default' => 'in-progress', // in-progress, success, error
|
||||
]);
|
||||
$table->addColumn('filesystem_id', Types::STRING, ['length' => 512]);
|
||||
|
||||
$table->addColumn('reason', Types::STRING, ['length' => 1024]);
|
||||
$table->addColumn('error', Types::STRING, [
|
||||
'length' => 1024,
|
||||
'default' => null,
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
// Index on date_updated, as we'll usually sort the query by this field
|
||||
$table->addIndex(['date_updated'], 'IDX_geolocation_date_updated');
|
||||
// Index on filesystem_id, as we'll usually filter the query by this field
|
||||
$table->addIndex(['filesystem_id'], 'IDX_geolocation_status_filesystem');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
|
||||
$schema->dropTable(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
@ -28,20 +28,21 @@ use function trim;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
|
||||
|
||||
final class QrCodeParams
|
||||
final readonly class QrCodeParams
|
||||
{
|
||||
private const MIN_SIZE = 50;
|
||||
private const MAX_SIZE = 1000;
|
||||
private const SUPPORTED_FORMATS = ['png', 'svg'];
|
||||
private const int MIN_SIZE = 50;
|
||||
private const int MAX_SIZE = 1000;
|
||||
private const array SUPPORTED_FORMATS = ['png', 'svg'];
|
||||
|
||||
private function __construct(
|
||||
public readonly int $size,
|
||||
public readonly int $margin,
|
||||
public readonly WriterInterface $writer,
|
||||
public readonly ErrorCorrectionLevel $errorCorrectionLevel,
|
||||
public readonly RoundBlockSizeMode $roundBlockSizeMode,
|
||||
public readonly ColorInterface $color,
|
||||
public readonly ColorInterface $bgColor,
|
||||
public int $size,
|
||||
public int $margin,
|
||||
public WriterInterface $writer,
|
||||
public ErrorCorrectionLevel $errorCorrectionLevel,
|
||||
public RoundBlockSizeMode $roundBlockSizeMode,
|
||||
public ColorInterface $color,
|
||||
public ColorInterface $bgColor,
|
||||
public bool $disableLogo,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -57,6 +58,7 @@ final class QrCodeParams
|
||||
roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults),
|
||||
color: self::resolveColor($query, $defaults),
|
||||
bgColor: self::resolveBackgroundColor($query, $defaults),
|
||||
disableLogo: isset($query['logo']) && $query['logo'] === 'disable',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,7 @@ readonly class QrCodeAction implements MiddlewareInterface
|
||||
private function buildQrCode(Builder $qrCodeBuilder, QrCodeParams $params): ResultInterface
|
||||
{
|
||||
$logoUrl = $this->options->logoUrl;
|
||||
if ($logoUrl === null) {
|
||||
if ($logoUrl === null || $params->disableLogo) {
|
||||
return $qrCodeBuilder->build();
|
||||
}
|
||||
|
||||
|
@ -36,6 +36,7 @@ enum EnvVars: string
|
||||
case DB_HOST = 'DB_HOST';
|
||||
case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET';
|
||||
case DB_PORT = 'DB_PORT';
|
||||
case DB_USE_ENCRYPTION = 'DB_USE_ENCRYPTION';
|
||||
case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY';
|
||||
case CACHE_NAMESPACE = 'CACHE_NAMESPACE';
|
||||
case REDIS_SERVERS = 'REDIS_SERVERS';
|
||||
@ -84,7 +85,7 @@ enum EnvVars: string
|
||||
case IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED';
|
||||
case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
|
||||
case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
|
||||
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
|
||||
case REDIRECT_EXTRA_PATH_MODE = 'REDIRECT_EXTRA_PATH_MODE';
|
||||
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
|
||||
case ROBOTS_ALLOW_ALL_SHORT_URLS = 'ROBOTS_ALLOW_ALL_SHORT_URLS';
|
||||
case ROBOTS_USER_AGENTS = 'ROBOTS_USER_AGENTS';
|
||||
@ -92,6 +93,8 @@ enum EnvVars: string
|
||||
case MEMORY_LIMIT = 'MEMORY_LIMIT';
|
||||
case INITIAL_API_KEY = 'INITIAL_API_KEY';
|
||||
case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD';
|
||||
/** @deprecated Use REDIRECT_EXTRA_PATH */
|
||||
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
|
||||
|
||||
public function loadFromEnv(): mixed
|
||||
{
|
||||
@ -125,11 +128,13 @@ enum EnvVars: string
|
||||
self::DEFAULT_SHORT_CODES_LENGTH => DEFAULT_SHORT_CODES_LENGTH,
|
||||
self::SHORT_URL_MODE => ShortUrlMode::STRICT->value,
|
||||
self::IS_HTTPS_ENABLED, self::AUTO_RESOLVE_TITLES => true,
|
||||
self::REDIRECT_APPEND_EXTRA_PATH,
|
||||
self::MULTI_SEGMENT_SLUGS_ENABLED,
|
||||
self::SHORT_URL_TRAILING_SLASH => false,
|
||||
self::DEFAULT_DOMAIN, self::BASE_PATH => '',
|
||||
self::CACHE_NAMESPACE => 'Shlink',
|
||||
// Deprecated. In Shlink 5.0.0, add default value for REDIRECT_EXTRA_PATH_MODE
|
||||
self::REDIRECT_APPEND_EXTRA_PATH => false,
|
||||
// self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value,
|
||||
|
||||
self::REDIS_PUB_SUB_ENABLED,
|
||||
self::MATOMO_ENABLED,
|
||||
@ -143,6 +148,7 @@ enum EnvVars: string
|
||||
'mssql' => '1433',
|
||||
default => '3306',
|
||||
},
|
||||
self::DB_USE_ENCRYPTION => false,
|
||||
|
||||
self::MERCURE_INTERNAL_HUB_URL => self::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
|
||||
|
||||
|
@ -17,8 +17,8 @@ use function urlencode;
|
||||
|
||||
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||
{
|
||||
private const DOMAIN_PLACEHOLDER = '{DOMAIN}';
|
||||
private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}';
|
||||
private const string DOMAIN_PLACEHOLDER = '{DOMAIN}';
|
||||
private const string ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}';
|
||||
|
||||
public function __construct(
|
||||
private readonly RedirectResponseHelperInterface $redirectResponseHelper,
|
||||
|
13
module/Core/src/Config/Options/ExtraPathMode.php
Normal file
13
module/Core/src/Config/Options/ExtraPathMode.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config\Options;
|
||||
|
||||
enum ExtraPathMode: string
|
||||
{
|
||||
/** URLs with extra path will not match a short URL */
|
||||
case DEFAULT = 'default';
|
||||
/** The extra path will be appended to the long URL */
|
||||
case APPEND = 'append';
|
||||
/** The extra path will be ignored */
|
||||
case IGNORE = 'ignore';
|
||||
}
|
@ -22,10 +22,10 @@ final readonly class UrlShortenerOptions
|
||||
public string $schema = 'http',
|
||||
public int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH,
|
||||
public bool $autoResolveTitles = false,
|
||||
public bool $appendExtraPath = false,
|
||||
public bool $multiSegmentSlugsEnabled = false,
|
||||
public bool $trailingSlashEnabled = false,
|
||||
public ShortUrlMode $mode = ShortUrlMode::STRICT,
|
||||
public ExtraPathMode $extraPathMode = ExtraPathMode::DEFAULT,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -35,17 +35,26 @@ final readonly class UrlShortenerOptions
|
||||
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(),
|
||||
MIN_SHORT_CODES_LENGTH,
|
||||
);
|
||||
$mode = EnvVars::SHORT_URL_MODE->loadFromEnv();
|
||||
|
||||
// Deprecated. Initialize extra path from REDIRECT_APPEND_EXTRA_PATH.
|
||||
$appendExtraPath = EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv();
|
||||
$extraPathMode = $appendExtraPath ? ExtraPathMode::APPEND : ExtraPathMode::DEFAULT;
|
||||
|
||||
// If REDIRECT_EXTRA_PATH_MODE was explicitly provided, it has precedence
|
||||
$extraPathModeFromEnv = EnvVars::REDIRECT_EXTRA_PATH_MODE->loadFromEnv();
|
||||
if ($extraPathModeFromEnv !== null) {
|
||||
$extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT;
|
||||
}
|
||||
|
||||
return new self(
|
||||
defaultDomain: EnvVars::DEFAULT_DOMAIN->loadFromEnv(),
|
||||
schema: ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv()) ? 'https' : 'http',
|
||||
defaultShortCodesLength: $shortCodesLength,
|
||||
autoResolveTitles: (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(),
|
||||
appendExtraPath: (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(),
|
||||
multiSegmentSlugsEnabled: (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(),
|
||||
trailingSlashEnabled: (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(),
|
||||
mode: ShortUrlMode::tryFrom($mode) ?? ShortUrlMode::STRICT,
|
||||
mode: ShortUrlMode::tryFrom(EnvVars::SHORT_URL_MODE->loadFromEnv()) ?? ShortUrlMode::STRICT,
|
||||
extraPathMode: $extraPathMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,7 @@ use function array_map;
|
||||
|
||||
class BasePathPrefixer
|
||||
{
|
||||
private const ELEMENTS_WITH_PATH = ['routes', 'middleware_pipeline'];
|
||||
private const array ELEMENTS_WITH_PATH = ['routes', 'middleware_pipeline'];
|
||||
|
||||
public function __invoke(array $config): array
|
||||
{
|
||||
|
@ -11,8 +11,8 @@ use function str_replace;
|
||||
|
||||
class MultiSegmentSlugProcessor
|
||||
{
|
||||
private const SINGLE_SEGMENT_PATTERN = '{shortCode}';
|
||||
private const MULTI_SEGMENT_PATTERN = '{shortCode:.+}';
|
||||
private const string SINGLE_SEGMENT_PATTERN = '{shortCode}';
|
||||
private const string MULTI_SEGMENT_PATTERN = '{shortCode:.+}';
|
||||
|
||||
public function __invoke(array $config): array
|
||||
{
|
||||
|
@ -11,7 +11,7 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
|
||||
class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface
|
||||
{
|
||||
public const DEFAULT_AUTHORITY = 'DEFAULT';
|
||||
public const string DEFAULT_AUTHORITY = 'DEFAULT';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $authority,
|
||||
|
@ -11,10 +11,10 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
|
||||
/** @extends InputFilter<mixed> */
|
||||
class DomainRedirectsInputFilter extends InputFilter
|
||||
{
|
||||
public const DOMAIN = 'domain';
|
||||
public const BASE_URL_REDIRECT = 'baseUrlRedirect';
|
||||
public const REGULAR_404_REDIRECT = 'regular404Redirect';
|
||||
public const INVALID_SHORT_URL_REDIRECT = 'invalidShortUrlRedirect';
|
||||
public const string DOMAIN = 'domain';
|
||||
public const string BASE_URL_REDIRECT = 'baseUrlRedirect';
|
||||
public const string REGULAR_404_REDIRECT = 'regular404Redirect';
|
||||
public const string INVALID_SHORT_URL_REDIRECT = 'invalidShortUrlRedirect';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
|
@ -17,9 +17,9 @@ use function sprintf;
|
||||
|
||||
class NotFoundTemplateHandler implements RequestHandlerInterface
|
||||
{
|
||||
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
|
||||
public const NOT_FOUND_TEMPLATE = '404.html';
|
||||
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
|
||||
private const string TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
|
||||
public const string NOT_FOUND_TEMPLATE = '404.html';
|
||||
public const string INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
|
||||
|
||||
private Closure $readFile;
|
||||
|
||||
|
@ -6,13 +6,15 @@ namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\GeoLiteDbCreated;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
|
||||
use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/** @todo Rename to UpdateGeolocationDb */
|
||||
readonly class UpdateGeoLiteDb
|
||||
{
|
||||
public function __construct(
|
||||
@ -24,21 +26,35 @@ readonly class UpdateGeoLiteDb
|
||||
|
||||
public function __invoke(): void
|
||||
{
|
||||
$beforeDownload = fn (bool $olderDbExists) => $this->logger->notice(
|
||||
sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
);
|
||||
$messageLogged = false;
|
||||
$handleProgress = function (int $total, int $downloaded, bool $olderDbExists) use (&$messageLogged): void {
|
||||
if ($messageLogged || $total > $downloaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
$messageLogged = true;
|
||||
$this->logger->notice(sprintf('Finished %s GeoLite2 db file', $olderDbExists ? 'updating' : 'downloading'));
|
||||
};
|
||||
|
||||
try {
|
||||
$result = $this->dbUpdater->checkDbUpdate($beforeDownload, $handleProgress);
|
||||
$result = $this->dbUpdater->checkDbUpdate(
|
||||
new class ($this->logger) implements GeolocationDownloadProgressHandlerInterface {
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private bool $messageLogged = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function beforeDownload(bool $olderDbExists): void
|
||||
{
|
||||
$this->logger->notice(
|
||||
sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
);
|
||||
}
|
||||
|
||||
public function handleProgress(int $total, int $downloaded, bool $olderDbExists): void
|
||||
{
|
||||
if ($this->messageLogged || $total > $downloaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->messageLogged = true;
|
||||
$this->logger->notice(
|
||||
sprintf('Finished %s GeoLite2 db file', $olderDbExists ? 'updating' : 'downloading'),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if ($result === GeolocationResult::DB_CREATED) {
|
||||
$this->eventDispatcher->dispatch(new GeoLiteDbCreated());
|
||||
}
|
||||
|
@ -16,8 +16,8 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Cannot delete short URL';
|
||||
public const ERROR_CODE = 'invalid-short-url-deletion';
|
||||
private const string TITLE = 'Cannot delete short URL';
|
||||
public const string ERROR_CODE = 'invalid-short-url-deletion';
|
||||
|
||||
public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self
|
||||
{
|
||||
|
@ -15,8 +15,8 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Domain not found';
|
||||
public const ERROR_CODE = 'domain-not-found';
|
||||
private const string TITLE = 'Domain not found';
|
||||
public const string ERROR_CODE = 'domain-not-found';
|
||||
|
||||
private function __construct(string $message, array $additional)
|
||||
{
|
||||
|
@ -14,8 +14,8 @@ class ForbiddenTagOperationException extends DomainException implements ProblemD
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Forbidden tag operation';
|
||||
public const ERROR_CODE = 'forbidden-tag-operation';
|
||||
private const string TITLE = 'Forbidden tag operation';
|
||||
public const string ERROR_CODE = 'forbidden-tag-operation';
|
||||
|
||||
public static function forDeletion(): self
|
||||
{
|
||||
|
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
private function __construct(string $message, public readonly bool $olderDbExists, Throwable|null $prev = null)
|
||||
{
|
||||
parent::__construct($message, previous: $prev);
|
||||
}
|
||||
|
||||
public static function withOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
return new self(
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
olderDbExists: true,
|
||||
prev: $prev,
|
||||
);
|
||||
}
|
||||
|
||||
public static function withoutOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
return new self(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
olderDbExists: false,
|
||||
prev: $prev,
|
||||
);
|
||||
}
|
||||
}
|
@ -16,8 +16,8 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid custom slug';
|
||||
public const ERROR_CODE = 'non-unique-slug';
|
||||
private const string TITLE = 'Invalid custom slug';
|
||||
public const string ERROR_CODE = 'non-unique-slug';
|
||||
|
||||
public static function fromSlug(string $slug, string|null $domain = null): self
|
||||
{
|
||||
|
@ -16,8 +16,8 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Short URL not found';
|
||||
public const ERROR_CODE = 'short-url-not-found';
|
||||
private const string TITLE = 'Short URL not found';
|
||||
public const string ERROR_CODE = 'short-url-not-found';
|
||||
|
||||
public static function fromNotFound(ShortUrlIdentifier $identifier): self
|
||||
{
|
||||
|
@ -16,8 +16,8 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Tag conflict';
|
||||
public const ERROR_CODE = 'tag-conflict';
|
||||
private const string TITLE = 'Tag conflict';
|
||||
public const string ERROR_CODE = 'tag-conflict';
|
||||
|
||||
public static function forExistingTag(Renaming $renaming): self
|
||||
{
|
||||
|
@ -15,8 +15,8 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Tag not found';
|
||||
public const ERROR_CODE = 'tag-not-found';
|
||||
private const string TITLE = 'Tag not found';
|
||||
public const string ERROR_CODE = 'tag-not-found';
|
||||
|
||||
public static function fromTag(string $tag): self
|
||||
{
|
||||
|
@ -21,8 +21,8 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid data';
|
||||
public const ERROR_CODE = 'invalid-data';
|
||||
private const string TITLE = 'Invalid data';
|
||||
public const string ERROR_CODE = 'invalid-data';
|
||||
|
||||
private array $invalidElements;
|
||||
|
||||
|
77
module/Core/src/Geolocation/Entity/GeolocationDbUpdate.php
Normal file
77
module/Core/src/Geolocation/Entity/GeolocationDbUpdate.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Geolocation\Entity;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Exception\RuntimeException;
|
||||
|
||||
use function stat;
|
||||
|
||||
class GeolocationDbUpdate extends AbstractEntity
|
||||
{
|
||||
private function __construct(
|
||||
public readonly string $reason,
|
||||
private readonly string $filesystemId,
|
||||
private GeolocationDbUpdateStatus $status = GeolocationDbUpdateStatus::IN_PROGRESS,
|
||||
private readonly Chronos $dateCreated = new Chronos(),
|
||||
private Chronos $dateUpdated = new Chronos(),
|
||||
private string|null $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function withReason(string $reason): self
|
||||
{
|
||||
return new self($reason, self::currentFilesystemId());
|
||||
}
|
||||
|
||||
public static function currentFilesystemId(): string
|
||||
{
|
||||
$system = stat(__FILE__);
|
||||
if (! $system) {
|
||||
throw new RuntimeException('It was not possible to resolve filesystem ID via stat function');
|
||||
}
|
||||
|
||||
return (string) $system['dev'];
|
||||
}
|
||||
|
||||
public function finishSuccessfully(): self
|
||||
{
|
||||
$this->dateUpdated = Chronos::now();
|
||||
$this->status = GeolocationDbUpdateStatus::SUCCESS;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function finishWithError(string $error): self
|
||||
{
|
||||
$this->error = $error;
|
||||
$this->dateUpdated = Chronos::now();
|
||||
$this->status = GeolocationDbUpdateStatus::ERROR;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param positive-int $days
|
||||
*/
|
||||
public function isOlderThan(int $days): bool
|
||||
{
|
||||
return Chronos::now()->greaterThan($this->dateUpdated->addDays($days));
|
||||
}
|
||||
|
||||
public function isInProgress(): bool
|
||||
{
|
||||
return $this->status === GeolocationDbUpdateStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function isError(): bool
|
||||
{
|
||||
return $this->status === GeolocationDbUpdateStatus::ERROR;
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === GeolocationDbUpdateStatus::SUCCESS;
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Geolocation\Entity;
|
||||
|
||||
enum GeolocationDbUpdateStatus: string
|
||||
{
|
||||
case IN_PROGRESS = 'in-progress';
|
||||
case SUCCESS = 'success';
|
||||
case ERROR = 'error';
|
||||
}
|
164
module/Core/src/Geolocation/GeolocationDbUpdater.php
Normal file
164
module/Core/src/Geolocation/GeolocationDbUpdater.php
Normal file
@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Geolocation;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Geolocation\Entity\GeolocationDbUpdate;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
readonly class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
{
|
||||
private const string LOCK_NAME = 'geolocation-db-update';
|
||||
|
||||
public function __construct(
|
||||
private DbUpdaterInterface $dbUpdater,
|
||||
private LockFactory $locker,
|
||||
private TrackingOptions $trackingOptions,
|
||||
private EntityManagerInterface $em,
|
||||
private int $maxRecentAttemptsToCheck = 15, // TODO Make this configurable
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(
|
||||
GeolocationDownloadProgressHandlerInterface|null $downloadProgressHandler = null,
|
||||
): GeolocationResult {
|
||||
if (! $this->trackingOptions->isGeolocationRelevant()) {
|
||||
return GeolocationResult::CHECK_SKIPPED;
|
||||
}
|
||||
|
||||
|
||||
$lock = $this->locker->createLock(self::LOCK_NAME);
|
||||
$lock->acquire(blocking: true);
|
||||
|
||||
try {
|
||||
return $this->downloadIfNeeded($downloadProgressHandler);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadIfNeeded(
|
||||
GeolocationDownloadProgressHandlerInterface|null $downloadProgressHandler,
|
||||
): GeolocationResult {
|
||||
$recentDownloads = $this->em->getRepository(GeolocationDbUpdate::class)->findBy(
|
||||
criteria: ['filesystemId' => GeolocationDbUpdate::currentFilesystemId()],
|
||||
orderBy: ['dateUpdated' => 'DESC'],
|
||||
limit: $this->maxRecentAttemptsToCheck,
|
||||
);
|
||||
$mostRecentDownload = $recentDownloads[0] ?? null;
|
||||
|
||||
// If most recent attempt is in progress, skip check.
|
||||
// This is a safety check in case the lock is released before the previous download has finished.
|
||||
if ($mostRecentDownload?->isInProgress()) {
|
||||
return GeolocationResult::UPDATE_IN_PROGRESS;
|
||||
}
|
||||
|
||||
$amountOfErrorsSinceLastSuccess = 0;
|
||||
foreach ($recentDownloads as $recentDownload) {
|
||||
// Count attempts until a success is found
|
||||
if ($recentDownload->isSuccess()) {
|
||||
break;
|
||||
}
|
||||
$amountOfErrorsSinceLastSuccess++;
|
||||
}
|
||||
|
||||
// If max amount of consecutive errors has been reached and the most recent one is not old enough, skip download
|
||||
// for 2 days to avoid hitting potential API limits in geolocation services
|
||||
$lastAttemptIsError = $mostRecentDownload !== null && $mostRecentDownload->isError();
|
||||
// FIXME Once max errors are reached there will be one attempt every 2 days, but it should be 15 attempts every
|
||||
// 2 days. Leaving like this for simplicity for now.
|
||||
$maxConsecutiveErrorsReached = $amountOfErrorsSinceLastSuccess === $this->maxRecentAttemptsToCheck;
|
||||
if ($lastAttemptIsError && $maxConsecutiveErrorsReached && ! $mostRecentDownload->isOlderThan(days: 2)) {
|
||||
return GeolocationResult::MAX_ERRORS_REACHED;
|
||||
}
|
||||
|
||||
// Try to download if:
|
||||
// - There are no attempts tracked
|
||||
// - The database file does not exist
|
||||
// - Last update errored (and implicitly, the max amount of consecutive errors has not been reached)
|
||||
// - Most recent attempt is older than 30 days (and implicitly, successful)
|
||||
$reasonMatch = match (true) {
|
||||
$mostRecentDownload === null => [false, 'No download attempts tracked for this instance'],
|
||||
! $this->dbUpdater->databaseFileExists() => [false, 'Geolocation db file does not exist'],
|
||||
$lastAttemptIsError => [true, 'Max consecutive errors not reached'],
|
||||
$mostRecentDownload->isOlderThan(days: 30) => [true, 'Last successful attempt is old enough'],
|
||||
default => null,
|
||||
};
|
||||
if ($reasonMatch !== null) {
|
||||
[$olderDbExists, $reason] = $reasonMatch;
|
||||
return $this->downloadAndTrackUpdate($downloadProgressHandler, $olderDbExists, $reason);
|
||||
}
|
||||
|
||||
return GeolocationResult::DB_IS_UP_TO_DATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadAndTrackUpdate(
|
||||
GeolocationDownloadProgressHandlerInterface|null $downloadProgressHandler,
|
||||
bool $olderDbExists,
|
||||
string $reason,
|
||||
): GeolocationResult {
|
||||
$dbUpdate = GeolocationDbUpdate::withReason($reason);
|
||||
$this->em->persist($dbUpdate);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
$result = $this->downloadNewDb($downloadProgressHandler, $olderDbExists);
|
||||
$dbUpdate->finishSuccessfully();
|
||||
return $result;
|
||||
} catch (MissingLicenseException) {
|
||||
$dbUpdate->finishWithError('Geolocation license key is missing');
|
||||
return GeolocationResult::LICENSE_MISSING;
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
$dbUpdate->finishWithError(
|
||||
sprintf('%s Prev: %s', $e->getMessage(), $e->getPrevious()?->getMessage() ?? '-'),
|
||||
);
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$dbUpdate->finishWithError(sprintf('Unknown error: %s', $e->getMessage()));
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadNewDb(
|
||||
GeolocationDownloadProgressHandlerInterface|null $downloadProgressHandler,
|
||||
bool $olderDbExists,
|
||||
): GeolocationResult {
|
||||
$downloadProgressHandler?->beforeDownload($olderDbExists);
|
||||
|
||||
try {
|
||||
$this->dbUpdater->downloadFreshCopy(
|
||||
static fn (int $total, int $downloaded)
|
||||
=> $downloadProgressHandler?->handleProgress($total, $downloaded, $olderDbExists),
|
||||
);
|
||||
return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED;
|
||||
} catch (DbUpdateException $e) {
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb($e)
|
||||
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,9 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\GeoLite;
|
||||
namespace Shlinkio\Shlink\Core\Geolocation;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
|
||||
interface GeolocationDbUpdaterInterface
|
||||
{
|
||||
@ -12,7 +12,6 @@ interface GeolocationDbUpdaterInterface
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(
|
||||
callable|null $beforeDownload = null,
|
||||
callable|null $handleProgress = null,
|
||||
GeolocationDownloadProgressHandlerInterface|null $downloadProgressHandler = null,
|
||||
): GeolocationResult;
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Geolocation;
|
||||
|
||||
interface GeolocationDownloadProgressHandlerInterface
|
||||
{
|
||||
/**
|
||||
* Invoked right before starting to download a geolocation DB file, and only if it needs to be downloaded.
|
||||
* @param $olderDbExists - Indicates if an older DB file already exists when this method is called
|
||||
*/
|
||||
public function beforeDownload(bool $olderDbExists): void;
|
||||
|
||||
/**
|
||||
* Invoked every time a new chunk of the new DB file is downloaded, with the total size of the file and how much has
|
||||
* already been downloaded.
|
||||
* @param $olderDbExists - Indicates if an older DB file already exists when this method is called
|
||||
*/
|
||||
public function handleProgress(int $total, int $downloaded, bool $olderDbExists): void;
|
||||
}
|
21
module/Core/src/Geolocation/GeolocationResult.php
Normal file
21
module/Core/src/Geolocation/GeolocationResult.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Geolocation;
|
||||
|
||||
enum GeolocationResult
|
||||
{
|
||||
/** Geolocation is not relevant, so updates are skipped */
|
||||
case CHECK_SKIPPED;
|
||||
/** Update is skipped because max amount of consecutive errors was reached */
|
||||
case MAX_ERRORS_REACHED;
|
||||
/** Update was skipped because a geolocation license key was not provided */
|
||||
case LICENSE_MISSING;
|
||||
/** A geolocation database didn't exist and has been created */
|
||||
case DB_CREATED;
|
||||
/** An outdated geolocation database existed and has been updated */
|
||||
case DB_UPDATED;
|
||||
/** Geolocation database does not need to be updated yet */
|
||||
case DB_IS_UP_TO_DATE;
|
||||
/** Geolocation db update is currently in progress */
|
||||
case UPDATE_IN_PROGRESS;
|
||||
}
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Importer;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository;
|
||||
@ -32,6 +33,7 @@ readonly class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||
private ShortUrlRelationResolverInterface $relationResolver,
|
||||
private ShortCodeUniquenessHelperInterface $shortCodeHelper,
|
||||
private DoctrineBatchHelperInterface $batchHelper,
|
||||
private ShortUrlRedirectRuleServiceInterface $redirectRuleService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -80,6 +82,7 @@ readonly class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
$shortUrlImporting->importRedirectRules($importedUrl->redirectRules, $this->em, $this->redirectRuleService);
|
||||
$resultMessage = $shortUrlImporting->importVisits(
|
||||
$this->batchHelper->wrapIterable($importedUrl->visits, 100),
|
||||
$this->em,
|
||||
|
@ -4,11 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Importer;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkRedirectRule;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
|
||||
|
||||
use function count;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function Shlinkio\Shlink\Core\normalizeDate;
|
||||
use function sprintf;
|
||||
|
||||
@ -20,12 +27,12 @@ final readonly class ShortUrlImporting
|
||||
|
||||
public static function fromExistingShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
return new self($shortUrl, false);
|
||||
return new self($shortUrl, isNew: false);
|
||||
}
|
||||
|
||||
public static function fromNewShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
return new self($shortUrl, true);
|
||||
return new self($shortUrl, isNew: true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -55,6 +62,42 @@ final readonly class ShortUrlImporting
|
||||
: sprintf('<comment>Skipped</comment>. Imported <info>%s</info> visits', $importedVisits);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ImportedShlinkRedirectRule[] $rules
|
||||
*/
|
||||
public function importRedirectRules(
|
||||
array $rules,
|
||||
EntityManagerInterface $em,
|
||||
ShortUrlRedirectRuleServiceInterface $redirectRuleService,
|
||||
): void {
|
||||
if ($this->isNew && count($rules) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shortUrl = $this->resolveShortUrl($em);
|
||||
$redirectRules = map(
|
||||
$rules,
|
||||
function (ImportedShlinkRedirectRule $rule, int|string|float $index) use ($shortUrl): ShortUrlRedirectRule {
|
||||
$conditions = new ArrayCollection();
|
||||
foreach ($rule->conditions as $cond) {
|
||||
$redirectCondition = RedirectCondition::fromImport($cond);
|
||||
if ($redirectCondition !== null) {
|
||||
$conditions->add($redirectCondition);
|
||||
}
|
||||
}
|
||||
|
||||
return new ShortUrlRedirectRule(
|
||||
shortUrl: $shortUrl,
|
||||
priority: ((int) $index) + 1,
|
||||
longUrl:$rule->longUrl,
|
||||
conditions: $conditions,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
$redirectRuleService->saveRulesForShortUrl($shortUrl, $redirectRules);
|
||||
}
|
||||
|
||||
private function resolveShortUrl(EntityManagerInterface $em): ShortUrl
|
||||
{
|
||||
// If wrapped ShortUrl has no ID, avoid trying to query the EM, as it would fail in Postgres.
|
||||
|
@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Exception\RuntimeException;
|
||||
|
||||
readonly class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
|
||||
{
|
||||
public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds
|
||||
public const int MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds
|
||||
|
||||
public function __construct(private MatomoOptions $options)
|
||||
{
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user