Compare commits

...

89 Commits

Author SHA1 Message Date
Alejandro Celaya
104b1e7d04 Merge pull request #2371 from shlinkio/develop
Release 4.4.4
2025-02-19 19:40:28 +01:00
Alejandro Celaya
af2d67695b Merge pull request #2370 from acelaya-forks/feature/missing-join-fix
Fix 500 error when listing non-orphan visits with short-url-depending API key
2025-02-19 19:37:36 +01:00
Alejandro Celaya
449a588796 Fix 500 error when listing non-orphan visits with short-url-depending API key 2025-02-19 19:33:44 +01:00
Alejandro Celaya
7bbc938743 Merge pull request #2369 from acelaya-forks/feature/redis-cluster-fix
Downgrade to symfony/lock 7.1.6
2025-02-19 17:55:53 +01:00
Alejandro Celaya
766758ff9b Downgrade to symfony/lock 7.1.6 2025-02-19 17:45:52 +01:00
Alejandro Celaya
bee9f2a9cc Merge pull request #2364 from shlinkio/develop
Release 4.4.3
2025-02-15 11:28:09 +01:00
Alejandro Celaya
63d943d59d Merge pull request #2363 from acelaya-forks/feature/find-url-perf
Fix unique_short_code_plus_domain index in Microsoft SQL
2025-02-15 11:24:26 +01:00
Alejandro Celaya
053e1f3073 Update changelog 2025-02-15 11:19:30 +01:00
Alejandro Celaya
f3da345bf3 Fix unique_short_code_plus_domain index in Microsoft SQL 2025-02-15 11:17:14 +01:00
Alejandro Celaya
745255736a Simplify query to find short URL when domain is null 2025-02-14 10:20:50 +01:00
Alejandro Celaya
8fd53afe3f Merge pull request #2361 from acelaya-forks/feature/lock-downgrade
Downgrade symfony/lock to v7.2.0 to work around redis issue
2025-02-14 08:52:33 +01:00
Alejandro Celaya
259635ea2a Downgrade symfony/lock to v7.2.0 to work around redis issue 2025-02-14 08:40:06 +01:00
Alejandro Celaya
a1f2e6dc5c Merge pull request #2359 from acelaya-forks/feature/multi-proxy-fix
Workaround for IP resolution from x-Forwarded-For with multiple proxies
2025-02-13 22:03:36 +01:00
Alejandro Celaya
81e07bf08d Merge pull request #2358 from acelaya-forks/feature/phpunit-12
Update to PHPUnit 12
2025-02-13 21:59:00 +01:00
Alejandro Celaya
c650a3e665 Workaround for IP resolution from x-Forwarded-For with multiple proxies 2025-02-13 21:52:38 +01:00
Alejandro Celaya
65c01034ff Update to PHPUnit 12 2025-02-13 10:35:58 +01:00
Alejandro Celaya
48f910aaaa Merge pull request #2355 from acelaya-forks/feature/openapi-warnings
Remove suppressed warnings when running openapi tools
2025-02-05 08:43:28 +01:00
Alejandro Celaya
e511e15a87 Remove suppressed warnings when running openapi tools 2025-02-05 08:39:22 +01:00
Alejandro Celaya
888dc84d3f Merge pull request #2348 from shlinkio/develop
Release 4.4.2
2025-01-29 12:08:51 +01:00
Alejandro Celaya
ed09bf90eb Tag v4.4.2 in changelog 2025-01-29 12:05:53 +01:00
Alejandro Celaya
0ddfcb75dd Merge pull request #2347 from acelaya-forks/feature/docker-arm
Get back docker image building for ARM architecture
2025-01-29 12:02:19 +01:00
Alejandro Celaya
193be55f0c Get back docker image building for ARM architecture 2025-01-29 11:59:42 +01:00
Alejandro Celaya
3ba7ad3839 Merge pull request #2345 from shlinkio/develop
Release 4.4.1 - fixes
2025-01-28 15:53:49 +01:00
Alejandro Celaya
7ffb64eee1 Do not build docker image for ARM 2025-01-28 15:51:20 +01:00
Alejandro Celaya
0a2cc554c6 Build docker image with buildx 0.19.2 2025-01-28 15:38:47 +01:00
Alejandro Celaya
7c2b918d5d Merge pull request #2344 from shlinkio/develop
Release 4.4.1
2025-01-28 10:15:24 +01:00
Alejandro Celaya
af783dea57 Add v4.4.1 to changelog 2025-01-28 10:12:15 +01:00
Alejandro Celaya
a68a17f6b4 Merge pull request #2343 from acelaya-forks/feature/defensive-title-encoding
Fix error when creating short URL for page with unsupported encoding
2025-01-28 10:11:04 +01:00
Alejandro Celaya
e9fe1ac5d4 Fix error when creating short URL for page with unsupported encoding 2025-01-28 10:04:30 +01:00
Alejandro Celaya
88e97f18ad Merge pull request #2342 from acelaya-forks/feature/too-many-connections
Close connections after every async job that uses the db
2025-01-27 15:48:22 +01:00
Alejandro Celaya
3372a2a9c8 Close connections after every async job that uses the db 2025-01-27 15:45:37 +01:00
Alejandro Celaya
f02a8c876c Merge pull request #2340 from acelaya-forks/feature/update-shlink-deps
Update shlink packages
2025-01-25 16:16:42 +01:00
Alejandro Celaya
1549509eb8 Update shlink packages 2025-01-25 16:13:40 +01:00
Alejandro Celaya
62fde5a8e2 Update changelog 2025-01-13 08:47:19 +01:00
Alejandro Celaya
221e061ea6 Merge pull request #2332 from MaZe3D/develop
Add ADDRESS environment vairable to define the listening interface.
2025-01-13 08:45:20 +01:00
Mark Orlando Zeller
9ad565f8c8 Add ADDRESS environment vairable to define the listening interface. 2025-01-10 22:10:51 +01:00
Alejandro Celaya
0a67f71b94 Merge pull request #2317 from shlinkio/develop
Release 4.4.0
2024-12-27 16:31:44 +01:00
Alejandro Celaya
11fa28e489 Merge pull request #2316 from acelaya-forks/feature/v4.4
Add v4.4.0 to changelog and update dependencies
2024-12-27 16:27:06 +01:00
Alejandro Celaya
d7e51b388e Add v4.4.0 to changelog and update dependencies 2024-12-27 16:24:25 +01:00
Alejandro Celaya
5ef2df3d53 Merge pull request #2315 from acelaya-forks/feature/import-redirect-rules
Implement logic to import redirect rules from other Shlink instances
2024-12-22 18:50:10 +01:00
Alejandro Celaya
9c251b3646 Update changelog 2024-12-22 18:41:58 +01:00
Alejandro Celaya
2807b9ce2f Fix ImportedLinksProcessorTest 2024-12-22 18:41:03 +01:00
Alejandro Celaya
2f39aff2fe Implement logic to import redirect rules from other Shlink instances 2024-12-22 12:42:06 +01:00
Alejandro Celaya
b8d7917691 Merge pull request #2314 from acelaya-forks/feature/database-ssl
Support encrypted connections to MySQL/Maria and Postgres
2024-12-20 09:54:59 +01:00
Alejandro Celaya
d228c16f82 Fix test for ip middleware 2024-12-20 09:52:30 +01:00
Alejandro Celaya
c34bfac6b1 Update installer with support for DB_USE_ENCRYPTION option 2024-12-20 09:29:28 +01:00
Alejandro Celaya
4e7d09035a Support encrypted connections to MySQL/Maria and Postgres 2024-12-19 09:00:01 +01:00
Alejandro Celaya
83570f5c25 Merge pull request #2313 from acelaya-forks/feature/qr-disable-logo
Allow QR code logo to be individually disabled
2024-12-18 09:14:47 +01:00
Alejandro Celaya
6ad8b03850 Allow QR code logo to be individually disabled 2024-12-18 09:10:53 +01:00
Alejandro Celaya
736e09adfe Merge pull request #2310 from acelaya-forks/feature/less-restrictive-custom-slugs
Be less restrictive on what characters are disallowed in custom slugs
2024-12-17 18:08:51 +01:00
Alejandro Celaya
e80af78e09 Be less restrictive on what characters are disallowed in custom slugs 2024-12-17 18:04:38 +01:00
Alejandro Celaya
d533adf7ce Merge pull request #2308 from acelaya-forks/feature/geolocation-updates
Improve how geolocation DB files are downloaded/updated
2024-12-16 20:21:35 +01:00
Alejandro Celaya
509ef668e6 Fix GeolocationDbUpdater test 2024-12-16 19:50:06 +01:00
Alejandro Celaya
e715a0fb6f Track reason for which a geolocation db download was attempted 2024-12-16 09:23:30 +01:00
Alejandro Celaya
72a962ec6d Handle differently when trying to update geolocation and already in progress 2024-12-15 12:03:01 +01:00
Alejandro Celaya
853c50a819 Fix some cases of database download in GeolocationDbUpdater 2024-12-15 11:34:38 +01:00
Alejandro Celaya
f10a9d3972 Simplify geolocation_db_updates indexes 2024-12-15 10:08:22 +01:00
Alejandro Celaya
a77e07f906 Refactor geolocation download logic based on database table 2024-12-15 10:05:32 +01:00
Alejandro Celaya
d4d97c3182 Create new table to track geolocation updates 2024-12-13 10:33:53 +01:00
Alejandro Celaya
55724dbff6 Merge pull request #2306 from acelaya-forks/feature/update-docker-images
Update docker images to Alpine 3.21
2024-12-12 09:06:49 +01:00
Alejandro Celaya
9e34183901 Update docker images to Alpine 3.21 2024-12-12 08:52:01 +01:00
Alejandro Celaya
88c283952c Merge pull request #2304 from acelaya-forks/feature/geolocation-services-refactor
Move GeolocationDbUpdater to Core module
2024-12-11 08:58:23 +01:00
Alejandro Celaya
2ede615da8 Fix DownloadGeoLiteDbCommandTest 2024-12-11 08:50:56 +01:00
Alejandro Celaya
84d12f6811 Move GeolocationDbUpdaterTest to Core module 2024-12-11 08:47:13 +01:00
Alejandro Celaya
4f3c2c7d2d Fix UpdateGeoLiteDbTest 2024-12-11 08:35:24 +01:00
Alejandro Celaya
b8ac9f3673 Add more strict parameter for GeolocationDbUpdater 2024-12-11 08:27:56 +01:00
Alejandro Celaya
06c0a94b31 Move GeolocationDbUpdater from CLI to Core module 2024-12-10 10:58:08 +01:00
Alejandro Celaya
5d12b1d952 Merge pull request #2302 from acelaya-forks/feature/openapi-names
Use the openapi terminology over swagger
2024-12-06 11:40:15 +01:00
Alejandro Celaya
85c4c09afa Use the openapi terminology over swagger 2024-12-06 11:36:47 +01:00
Alejandro Celaya
e7c83d0b38 Merge pull request #2300 from acelaya-forks/feature/drop-8.2-support
Drop support for PHP 8.2
2024-12-02 09:21:50 +01:00
Alejandro Celaya
58de998596 Drop support for PHP 8.2 2024-12-02 09:16:15 +01:00
Alejandro Celaya
bfaab6c494 Merge pull request #2298 from acelaya-forks/feature/ignore-extra-path
Allow the extra path to be ignored when redirecting
2024-12-01 12:37:56 +01:00
Alejandro Celaya
d83081f4e9 Update shlink-installer 2024-12-01 12:28:29 +01:00
Alejandro Celaya
c65349d265 Allow the extra path to be ignored when redirecting 2024-12-01 09:56:09 +01:00
Alejandro Celaya
e74ee793a0 Merge pull request #2297 from acelaya-forks/feature/docker-php-8.4
Update docker images to PHP 8.4
2024-11-30 18:35:03 +01:00
Alejandro Celaya
ede58efe96 Update docker images to PHP 8.4 2024-11-30 13:53:19 +01:00
Alejandro Celaya
3f30af4794 Merge pull request #2294 from acelaya-forks/feature/user-agent
Migrate from mobiledetectlib to phpuseragentparser
2024-11-28 12:11:29 +01:00
Alejandro Celaya
6331fa3ed3 Migrate from mobiledetectlib to phpuseragentparser 2024-11-28 12:05:10 +01:00
Alejandro Celaya
d121d4d496 Merge pull request #2289 from acelaya-forks/feature/delete-old-migrations
Delete some old migrations
2024-11-28 09:00:40 +01:00
Alejandro Celaya
8499087a3b Move DEFAULT_DOMAIN constant to domains module 2024-11-28 08:54:29 +01:00
Alejandro Celaya
bb72c96ebb Delete some old migrations 2024-11-26 10:17:28 +01:00
Alejandro Celaya
8d4f2bbd12 Merge pull request #2288 from shlinkio/develop
Release 4.3.1
2024-11-25 23:49:37 +01:00
Alejandro Celaya
557c74286b Add v4.3.1 to changelog 2024-11-25 23:45:02 +01:00
Alejandro Celaya
67abe21716 Merge pull request #2287 from acelaya-forks/feature/ms-index-fix
Fix columns order in unique_short_code_plus_domain index in MSSQL
2024-11-25 23:43:55 +01:00
Alejandro Celaya
33cea36b15 Fix columns order in unique_short_code_plus_domain index in MSSQL 2024-11-25 22:48:04 +01:00
Alejandro Celaya
4e8f3f737a Merge pull request #2286 from acelaya-forks/feature/crawler-detect
Use jaybizzle/crawler-detect instead of acelaya/crawler-detect
2024-11-25 22:21:14 +01:00
Alejandro Celaya
35b835ec7b Use jaybizzle/crawler-detect instead of acelaya/crawler-detect 2024-11-25 22:17:15 +01:00
Alejandro Celaya
eff4f1fca3 Merge pull request #2284 from acelaya-forks/feature/rka-ip-address
Go back to using akrabat/ip-address-middleware instead of acelaya/ip-address-middleware
2024-11-25 09:31:43 +01:00
Alejandro Celaya
6f6388b2fc Go back to using akrabat/ip-address-middleware instead of acelaya/ip-address-middleware 2024-11-25 09:23:43 +01:00
186 changed files with 1989 additions and 1194 deletions

View File

@@ -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:

View File

@@ -8,3 +8,5 @@ on:
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
with:
platforms: 'linux/arm64/v8,linux/amd64'

View File

@@ -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:

View File

@@ -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'

View File

@@ -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:

View File

@@ -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
View File

@@ -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

View File

@@ -4,6 +4,137 @@ 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.4] - 2025-02-19
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2366](https://github.com/shlinkio/shlink/issues/2366) Fix error "Cannot use 'SCRIPT' with redis-cluster" thrown when creating a lock while using a redis cluster.
* [#2368](https://github.com/shlinkio/shlink/issues/2368) Fix error when listing non-orphan visits using API key with `AUTHORED_SHORT_URLS` role.
## [4.4.3] - 2025-02-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2351](https://github.com/shlinkio/shlink/issues/2351) Fix visitor IP address resolution when Shlink is served behind more than one reverse proxy.
This regression was introduced due to a change in behavior in `akrabat/rka-ip-address-middleware`, that now picks the first address from the right after excluding all trusted proxies.
Since Shlink does not set trusted proxies, this means the first IP from the right is now picked instead of the first from the left, so we now reverse the list before trying to resolve the IP.
In the future, Shlink will allow you to define trusted proxies, to avoid other potential side effects because of this reversing of the list.
* [#2354](https://github.com/shlinkio/shlink/issues/2354) Fix error "NOSCRIPT No matching script. Please use EVAL" thrown when creating a lock in redis.
* [#2319](https://github.com/shlinkio/shlink/issues/2319) Fix unique index for `short_code` and `domain_id` in `short_urls` table not being used in Microsoft SQL engines for rows where `domain_id` is `null`.
## [4.4.2] - 2025-01-29
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2346](https://github.com/shlinkio/shlink/issues/2346) Get back docker images for ARM architectures.
## [4.4.1] - 2025-01-28
### Added
* [#2331](https://github.com/shlinkio/shlink/issues/2331) Add `ADDRESS` env var which allows to customize the IP address to which RoadRunner binds, when using the official docker image.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2341](https://github.com/shlinkio/shlink/issues/2341) Ensure all asynchronous jobs that interact with the database do not leave idle connections open.
* [#2334](https://github.com/shlinkio/shlink/issues/2334) Improve how page titles are encoded to UTF-8, falling back from mbstring to iconv if available, and ultimately using the original title in case of error, but never causing the short URL creation to fail.
## [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*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2285](https://github.com/shlinkio/shlink/issues/2285) Fix performance degradation when using Microsoft SQL due to incorrect order of columns in `unique_short_code_plus_domain` index.
## [4.3.0] - 2024-11-24
### Added
* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4.

View File

@@ -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 && \

View File

@@ -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.

View File

@@ -12,68 +12,68 @@
}
],
"require": {
"php": "^8.2",
"php": "^8.3",
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"acelaya/crawler-detect": "^1.3",
"acelaya/ip-address-middleware": "^2.4",
"akrabat/ip-address-middleware": "^2.6",
"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",
"laminas/laminas-config-aggregator": "^1.15",
"jaybizzle/crawler-detect": "^1.3",
"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/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-ip-geolocation": "^4.2",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2024.1",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^7.0",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.2",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.5",
"shlinkio/shlink-ip-geolocation": "^4.3",
"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.1.6",
"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": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.4",
"phpunit/php-code-coverage": "^12.0",
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.4.0",
"shlinkio/shlink-test-utils": "^4.2",
"symfony/var-dumper": "^7.1",
"shlinkio/shlink-test-utils": "^4.3.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,10 @@
"@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-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"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,

View File

@@ -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,
],
];

View File

@@ -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,

View File

@@ -2,8 +2,10 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
@@ -30,8 +32,19 @@ return [
'dependencies' => [
'factories' => [
IpAddress::class => IpAddressFactory::class,
// IpAddress::class => IpAddressFactory::class,
'actual_ip_address_middleware' => IpAddressFactory::class,
ReverseForwardedAddressesMiddlewareDecorator::class => ConfigAbstractFactory::class,
],
'aliases' => [
// Make sure the decorated middleware is resolved when getting IpAddress::class, to make this decoration
// transparent for other parts of the code
IpAddress::class => ReverseForwardedAddressesMiddlewareDecorator::class,
],
],
ConfigAbstractFactory::class => [
ReverseForwardedAddressesMiddlewareDecorator::class => ['actual_ip_address_middleware'],
],
];

View File

@@ -7,7 +7,7 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT:-8080}'
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'

View File

@@ -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

View File

@@ -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

View File

@@ -144,7 +144,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.15
image: dunglas/mercure:v0.18
ports:
- "3080:80"
environment:

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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,

View File

@@ -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
{

View File

@@ -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)
{

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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
{

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)
{

View File

@@ -15,7 +15,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
@@ -27,7 +26,7 @@ class GenerateKeyCommandTest extends TestCase
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$roleResolver = $this->createMock(RoleResolverInterface::class);
$roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]);
$roleResolver->method('determineRoles')->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
$this->commandTester = CliTestUtils::testerForCommand($command);

View File

@@ -40,11 +40,11 @@ class CreateDatabaseCommandTest extends TestCase
{
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(SharedLockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
@@ -60,7 +60,7 @@ class CreateDatabaseCommandTest extends TestCase
$em->method('getMetadataFactory')->willReturn($this->metadataFactory);
$noDbNameConn = $this->createMock(Connection::class);
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
$noDbNameConn->method('createSchemaManager')->willReturn($this->schemaManager);
$command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
$this->commandTester = CliTestUtils::testerForCommand($command);

View File

@@ -25,11 +25,11 @@ class MigrateDatabaseCommandTest extends TestCase
{
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(SharedLockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);

View File

@@ -70,7 +70,7 @@ class CreateShortUrlCommandTest extends TestCase
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
NonUniqueSlugException::fromSlug('my-slug'),
);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
@@ -112,7 +112,7 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
@@ -139,7 +139,7 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);

View File

@@ -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(

View File

@@ -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.'];
}

View File

@@ -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')->willReturn($this->lock);
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
@@ -67,7 +67,7 @@ class LocateVisitsCommandTest extends TestCase
$location = VisitLocation::fromGeolocation(Location::empty());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->exactly($expectedUnlocatedCalls))
->method('locateUnlocatedVisits')
->withAnyParameters()
@@ -83,7 +83,7 @@ class LocateVisitsCommandTest extends TestCase
$this->visitToLocation->expects(
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($args);
@@ -108,15 +108,15 @@ class LocateVisitsCommandTest extends TestCase
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::empty());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
->willReturnCallback($this->invokeHelperMethods($visit, $location));
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@@ -137,7 +137,7 @@ class LocateVisitsCommandTest extends TestCase
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
@@ -145,7 +145,7 @@ class LocateVisitsCommandTest extends TestCase
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException(
IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')),
);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@@ -165,11 +165,11 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function noActionIsPerformedIfLockIsAcquired(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(false);
$this->lock->method('acquire')->willReturn(false);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->visitToLocation->expects($this->never())->method('resolveVisitLocation');
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@@ -183,8 +183,8 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function showsProperMessageWhenGeoLiteUpdateFails(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_FAILURE);
$this->lock->method('acquire')->willReturn(true);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_FAILURE);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->commandTester->execute([]);
@@ -196,8 +196,8 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function providingAllFlagOnItsOwnDisplaysNotice(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->lock->method('acquire')->willReturn(true);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute(['--all' => true]);
$output = $this->commandTester->getDisplay();
@@ -208,7 +208,7 @@ class LocateVisitsCommandTest extends TestCase
#[Test, DataProvider('provideAbortInputs')]
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Execution aborted');

View File

@@ -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(),
);
}
}

View File

@@ -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(),
);
}
}

View File

@@ -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',

View File

@@ -25,11 +25,8 @@ class CliTestUtils
$command = $generator->testDouble(
Command::class,
mockObject: true,
markAsMockObject: true,
callOriginalConstructor: false,
callOriginalClone: false,
cloneArguments: false,
allowMockingUnknownTypes: false,
);
$command->method('getName')->willReturn($name);
$command->method('isEnabled')->willReturn(true);

View File

@@ -27,8 +27,8 @@ class ProcessRunnerTest extends TestCase
$this->helper = $this->createMock(ProcessHelper::class);
$this->formatter = $this->createMock(DebugFormatterHelper::class);
$helperSet = $this->createMock(HelperSet::class);
$helperSet->method('get')->with('debug_formatter')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->with()->willReturn($helperSet);
$helperSet->method('get')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->willReturn($helperSet);
$this->process = $this->createMock(Process::class);
$this->output = $this->createMock(OutputInterface::class);

View File

@@ -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,
@@ -223,6 +227,7 @@ return [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
'httpClient',
Config\Options\UrlShortenerOptions::class,
'Logger_Shlink',
],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
Config\Options\TrackingOptions::class,
@@ -240,6 +245,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 +263,7 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
ShortUrl\Helper\ShortCodeUniquenessHelper::class,
Util\DoctrineBatchHelper::class,
RedirectRule\ShortUrlRedirectRuleService::class,
],
Crawling\CrawlingHelper::class => [ShortUrl\Repository\CrawlableShortCodesQuery::class],

View File

@@ -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');
};

View File

@@ -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')

View File

@@ -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;
@@ -73,6 +73,9 @@ return (static function (): array {
],
'delegators' => [
EventDispatcher\Matomo\SendVisitToMatomo::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
@@ -94,6 +97,9 @@ return (static function (): array {
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\UpdateGeoLiteDb::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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'],
];

View File

@@ -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
{

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20241125213106 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->skipIf(! $this->isMsSql());
// Recreate unique_short_code_plus_domain index in Microsoft SQL, as it accidentally has the columns defined in
// the wrong order after Version20230130090946 migration
$shortUrls = $schema->getTable('short_urls');
$shortUrls->dropIndex('unique_short_code_plus_domain');
$shortUrls->addUniqueIndex(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
}
private function isMsSql(): bool
{
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
}
}

View 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);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Fix an incorrectly generated unique index in Microsoft SQL, on short_urls table, for short_code + domain_id columns.
* The index was generated only for rows where both columns were not null, which is not the desired behavior, as
* domain_id can be null.
* This is due to a bug in doctrine/dbal: https://github.com/doctrine/dbal/issues/3671
*
* FIXME DO NOT DELETE THIS MIGRATION! IT IS NOT POSSIBLE TO DO THIS IN ENTITY CONFIG CODE WHILE THE BUG EXISTS
*/
final class Version20250215100756 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->skipIf(! $this->isMicrosoftSql());
// Drop the existing unique index
$shortUrls = $schema->getTable('short_urls');
$shortUrls->dropIndex('unique_short_code_plus_domain');
}
public function postUp(Schema $schema): void
{
// The only way to get the index properly generated is by hardcoding the SQL.
// Since this migration is run Microsoft SQL only, it is safe to use this approach.
$this->connection->executeStatement(
'CREATE UNIQUE INDEX unique_short_code_plus_domain ON short_urls (short_code, domain_id);',
);
}
private function isMicrosoftSql(): bool
{
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
}
}

View File

@@ -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',
);
}

View File

@@ -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();
}

View File

@@ -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(),

View File

@@ -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,

View 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';
}

View File

@@ -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,
);
}

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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,

View File

@@ -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()
{

View File

@@ -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;

View File

@@ -11,7 +11,7 @@ class CloseDbConnectionEventListener
/** @var callable */
private $wrapped;
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(private readonly ReopeningEntityManagerInterface $em, callable $wrapped)
{
$this->wrapped = $wrapped;
}

View File

@@ -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());
}

View File

@@ -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
{

View File

@@ -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)
{

View File

@@ -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
{

View File

@@ -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,
);
}
}

View File

@@ -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
{

Some files were not shown because too many files have changed in this diff Show More