Compare commits

...

112 Commits

Author SHA1 Message Date
Alejandro Celaya
3266a0f85c Merge pull request #1494 from shlinkio/develop
Release 3.2.0
2022-08-05 19:04:45 +02:00
Alejandro Celaya
4629f1b03f Merge pull request #1493 from acelaya-forks/feature/update-deps
Updated to latest PHP version and native dependencies
2022-08-05 16:44:29 +02:00
Alejandro Celaya
fbd0c6cbea Merge pull request #1491 from acelaya-forks/feature/multi-segment-slugs
Feature/multi segment slugs
2022-08-05 16:31:38 +02:00
Alejandro Celaya
8260051c30 Updated to latest PHP version and native dependencies 2022-08-05 16:31:15 +02:00
Alejandro Celaya
c061c9c3ff Added v3.2.0 to changelog 2022-08-05 16:19:40 +02:00
Alejandro Celaya
8961191b2e Documented ADR for multi-segment slugs 2022-08-05 16:18:53 +02:00
Alejandro Celaya
fc0d99be41 Ensure filtering of custom-slug is different depending on the multi-sement lugsfeature flag 2022-08-05 08:38:05 +02:00
Alejandro Celaya
6834e72c4a Updated changelog 2022-08-04 17:15:35 +02:00
Alejandro Celaya
efe655f880 Enhanced ExtraPathRedirectMiddleware so that it supports multi-segment slugs 2022-08-04 17:03:08 +02:00
Alejandro Celaya
3d5ddce621 Ensured multi-segment feature flag affects how append_extra_path is checked 2022-08-04 16:10:54 +02:00
Alejandro Celaya
a3de3e15cb Updated installer with support for multi-segment slugs flag 2022-08-04 13:00:09 +02:00
Alejandro Celaya
619999d4f8 Added feature flag to enable/disable multi-segment support 2022-08-04 11:49:33 +02:00
Alejandro Celaya
7acf27dd38 Replaced usage of deprecated methods in DateRange class 2022-08-04 11:27:33 +02:00
Alejandro Celaya
ba517eeeb5 Moved routes config together, and ensure they are loaded last 2022-08-04 11:14:26 +02:00
Alejandro Celaya
fdd3e24967 Added support for multi-segment slugs 2022-08-03 19:32:59 +02:00
Alejandro Celaya
a570ce202a Updated to latest common 2022-08-03 12:59:09 +02:00
Alejandro Celaya
0a220bbc7a Allowed slashes on custom slugs during short URL creation 2022-08-01 17:32:54 +02:00
Alejandro Celaya
e0e511f56d Some improvements and comments in preparation of multi-segment slugs 2022-08-01 17:32:54 +02:00
Alejandro Celaya
d375dece0e Updated required deps 2022-08-01 17:32:54 +02:00
Alejandro Celaya
f801f265ed Added comments on places to change 2022-08-01 17:32:54 +02:00
Alejandro Celaya
1b4fc89b07 Merge pull request #1490 from acelaya-forks/feature/ci-composer-cache
Added cache for composer dependencies during CI
2022-08-01 17:32:07 +02:00
Alejandro Celaya
3ac2b77bf0 Removed composer cache due to a bug in github runner making it fail 2022-08-01 17:23:51 +02:00
Alejandro Celaya
b2ca4ad66b Migrated all workflows to ubuntu-22.04 2022-08-01 17:13:34 +02:00
Alejandro Celaya
25a7c7bc7f Added cache for composer dependencies during CI 2022-08-01 16:56:25 +02:00
Alejandro Celaya
6b009a4de4 Merge pull request #1489 from acelaya-forks/feature/command-error
Feature/command error
2022-08-01 12:25:05 +02:00
Alejandro Celaya
0b80a86e88 Updated changelog 2022-08-01 12:07:50 +02:00
Alejandro Celaya
b03f24d59a Ensured no arguments are passed form locate visits command to download geolite command, is it does not expect any 2022-08-01 12:06:38 +02:00
Alejandro Celaya
78ea13d366 Merge pull request #1488 from acelaya-forks/feature/redis-pub-sub
Feature/redis pub sub
2022-07-28 11:04:19 +02:00
Alejandro Celaya
8c2bdfba1c Refactored match to ifs with eary returns 2022-07-28 10:51:48 +02:00
Alejandro Celaya
3289968a93 Updated changelog 2022-07-28 10:46:24 +02:00
Alejandro Celaya
73ae754aa7 Created NotifyVisitToRedisTest 2022-07-28 10:36:52 +02:00
Alejandro Celaya
20a6e7e210 Created NotifyNewShortUrlToRedisTest 2022-07-28 10:33:26 +02:00
Alejandro Celaya
4cf433a994 Defined enum with supported remote systems 2022-07-28 10:25:55 +02:00
Alejandro Celaya
e36c4d397c Moved duplicated code in visit listeners to an abstract class 2022-07-27 18:18:36 +02:00
Alejandro Celaya
26037327f9 Moved duplicated code in short URL listeners to an abstract class 2022-07-27 18:06:47 +02:00
Alejandro Celaya
da6aa1d697 Integrated PublishUpdatesGenerator in NotifyVisitToRabbitMq listener 2022-07-27 17:41:48 +02:00
Alejandro Celaya
dada6aa3d1 Integrated PublishUpdatesGenerator in NotifyVisitToRedis listener 2022-07-27 16:55:19 +02:00
Alejandro Celaya
fa5ebb1677 Integrated PublishUpdatesGenerator in NotifyNewShortUrlToRedis listener 2022-07-27 16:47:21 +02:00
Alejandro Celaya
f071df325d Fixed NotifyNewShortUrlToRabbitMqTest 2022-07-27 10:26:18 +02:00
Alejandro Celaya
3c042c4011 Integrated PublishUpdatesGenerator in NotifyNewShortUrlToRabbitMq listener 2022-07-27 10:18:28 +02:00
Alejandro Celaya
7e8109caa3 Renamed MercureUpdatesGenerator to PublishingUpdatesGenerator to make it general purpose 2022-07-27 09:38:47 +02:00
Alejandro Celaya
d3add6d8e4 Added TODO 2022-07-26 12:18:58 +02:00
Alejandro Celaya
1b089749c0 Migrated mercure event listeners to use new publishing helper from shlink-common 2022-07-26 12:17:37 +02:00
Alejandro Celaya
791d6b7e57 Updated to latest common, with unified publishing API 2022-07-26 12:07:27 +02:00
Alejandro Celaya
233bb603cf Updated local redis config 2022-07-26 10:25:16 +02:00
Alejandro Celaya
db8a816524 Implemented redis pub/sub listeners 2022-07-26 10:17:50 +02:00
Alejandro Celaya
eff50ca202 Created new event listeners to send events to redis pub/sub 2022-07-25 18:23:13 +02:00
Alejandro Celaya
ceabb5ab2c Merge pull request #1486 from acelaya-forks/feature/backwards-compatible-rabbit-mq
Feature/backwards compatible rabbit mq
2022-07-25 12:55:28 +02:00
Alejandro Celaya
122c2fd5e6 Updated changelog 2022-07-25 12:34:40 +02:00
Alejandro Celaya
cd27a72982 Reduced duplicated code in NotifyNewShortUrlToRabbitMqTest 2022-07-25 12:31:32 +02:00
Alejandro Celaya
19b0f0d7dc Extended NotifyVisitToRabbitMqTest covering legacy and non-legacy use-cases 2022-07-25 12:30:28 +02:00
Alejandro Celaya
6ce2049935 Added support for legacy and new publishing of visits in RabbitMQ 2022-07-25 12:08:22 +02:00
Alejandro Celaya
53b937be63 Updated coding standard 2022-07-25 09:49:14 +02:00
Alejandro Celaya
71c8f99dab Merge pull request #1484 from acelaya-forks/feature/short-url-created-event
Feature/short url created event
2022-07-25 09:48:44 +02:00
Alejandro Celaya
9eb3fca726 Updated changelog 2022-07-25 09:32:09 +02:00
Alejandro Celaya
019bd4dec8 Created NotifyNewShortUrlToMercureTest 2022-07-25 09:30:25 +02:00
Alejandro Celaya
be1ce06c00 Updated asyn API spec 2022-07-25 09:04:15 +02:00
Alejandro Celaya
074bfe3db2 Updated MercureUpdatesGeneratorTest 2022-07-25 09:02:05 +02:00
Alejandro Celaya
34e72b42dc Implemented listener to publish new short URL events in Mercure 2022-07-24 19:00:48 +02:00
Alejandro Celaya
97d24d76d8 Fixed new short URL event payload to RabbitMQ, and started to add logic for Mercure 2022-07-24 12:37:57 +02:00
Alejandro Celaya
4d1af867a4 Extracted real-time update topic names to an enum 2022-07-24 12:06:00 +02:00
Alejandro Celaya
fc6b4c12b2 Configured publishing of new short URL events in RabbitMQ 2022-07-24 11:07:20 +02:00
Alejandro Celaya
405c6de591 Created NotifyNewShortUrlToRabbitMq test 2022-07-24 10:53:42 +02:00
Alejandro Celaya
47bfa5fcc0 Simplified NotifyNewShortUrlToRabbitMq 2022-07-24 10:18:19 +02:00
Alejandro Celaya
67d91d5fc5 Migrated rabbit integration to RabbitMqPublishingHelper from shlink-common 2022-07-24 10:12:26 +02:00
Alejandro Celaya
f832c56adb Moved Mercure and RabbitMq event listeners to their own subnamespaces 2022-07-21 20:07:28 +02:00
Alejandro Celaya
1aa9ae680e Merge pull request #1479 from acelaya-forks/feature/unknown-visits
Added missing implements JsonSerializable on VisitLocation that got l…
2022-07-18 20:43:02 +02:00
Alejandro Celaya
c4b30db82d Added missing implements JsonSerializable on VisitLocation that got lost when VisitLocationInterface was removed 2022-07-18 20:23:27 +02:00
Alejandro Celaya
abd9f3c6be Removed style checks disabling due to bug on php code sniffer 2022-07-04 17:12:38 +02:00
Alejandro Celaya
3de3594282 Merge pull request #1465 from jsoref/spelling
Spelling
2022-06-08 07:31:25 +02:00
Alejandro Celaya
ed5816d464 Fixed merge conflicts 2022-06-04 11:43:02 +02:00
Alejandro Celaya
3d43bdbb49 Merge pull request #1462 from acelaya-forks/feature/search-with-all-tags
Fixed error when filtering short URLs by ALL tags and search term
2022-06-04 11:37:14 +02:00
Alejandro Celaya
1ab492ce5b Added missing test case 2022-06-04 11:22:10 +02:00
Alejandro Celaya
de30c6ad79 Fixed error when filtering short URLs by ALL tags and search term 2022-06-04 11:20:08 +02:00
Alejandro Celaya
f5a48ff98d Merge pull request #1460 from acelaya-forks/feature/monolog3
Feature/monolog3
2022-06-04 09:11:31 +02:00
Alejandro Celaya
8493ee5b83 Updated changelog 2022-06-04 09:00:00 +02:00
Alejandro Celaya
52a6d55e5d Updated to monolog 3 2022-06-04 08:59:17 +02:00
Josh Soref
7142295aa5 spelling: urls
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
8b65be26a6 spelling: the
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
60f5deb494 spelling: received
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
0fc09e6dd3 spelling: monolog
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
0c4ccf4e3e spelling: middleware
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
a0e79bf446 spelling: microsoft
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
aa356ad7c7 spelling: github
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 23:36:42 -04:00
Josh Soref
9e0e384d46 spelling: campaign
Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>
2022-05-30 22:39:08 -04:00
Alejandro Celaya
a20b99e643 Merge pull request #1451 from acelaya-forks/feature/missing-visits-commands
Feature/missing visits commands
2022-05-24 18:44:56 +02:00
Alejandro Celaya
fe4237b2b1 Updated changelog 2022-05-24 18:00:17 +02:00
Alejandro Celaya
4146835f6f Created GetOrhanVisitsCommand test 2022-05-24 17:59:06 +02:00
Alejandro Celaya
5201ea4516 Created tests for short-url-visits commands 2022-05-24 17:54:44 +02:00
Alejandro Celaya
fba7b36245 Improved GetShortUrlVisitsCommand test 2022-05-24 17:44:12 +02:00
Alejandro Celaya
353ac0fc0c Added logic to resolve extra columns on visits commands 2022-05-23 21:19:59 +02:00
Alejandro Celaya
00002b1e24 Renamed some visits commands 2022-05-23 20:47:37 +02:00
Alejandro Celaya
aa32830671 Added note to readme 2022-05-23 07:55:33 +02:00
Alejandro Celaya
12b8100d89 Created visits commands for orphan, non-orphan and domain 2022-05-22 19:34:08 +02:00
Alejandro Celaya
72e56d271d Created tags visits command, with abstract class wrapping common logic for visits lists commands 2022-05-22 19:22:29 +02:00
Alejandro Celaya
2b69f5eff4 Merge pull request #1449 from acelaya-forks/feature/title-html-entities
Feature/title html entities
2022-05-22 10:12:54 +02:00
Alejandro Celaya
f224bb98c4 Updated changelog 2022-05-22 08:30:46 +02:00
Alejandro Celaya
ec17eb3fbc Ensured html entities are parsed when auto-resolving titles 2022-05-22 08:29:26 +02:00
Alejandro Celaya
358b600713 Fixed merge conflicts 2022-05-09 08:21:19 +02:00
Alejandro Celaya
e8c7bee924 Added SemVer constraints for some deps which were left with specific commits by mistake 2022-04-23 19:30:33 +02:00
Alejandro Celaya
24b06c24dc Merge pull request #1434 from acelaya-forks/feature/drop-php-8.0
Feature/drop php 8.0
2022-04-23 19:26:02 +02:00
Alejandro Celaya
6fdd764a35 Updated to phpcov 8.2.1 2022-04-23 19:11:23 +02:00
Alejandro Celaya
2400d1f265 Removed unused method 2022-04-23 19:07:36 +02:00
Alejandro Celaya
cdef430b0b Set ShortUrlIdentifier constructor to private 2022-04-23 19:01:02 +02:00
Alejandro Celaya
6074e4ae2c Updated changelog 2022-04-23 18:57:39 +02:00
Alejandro Celaya
6ada704bc3 Moved TagsMode to its own enum 2022-04-23 18:56:27 +02:00
Alejandro Celaya
e8f7daac6f Converted Role constants to enum 2022-04-23 18:41:16 +02:00
Alejandro Celaya
404455928e Converted visit types into enum 2022-04-23 18:19:16 +02:00
Alejandro Celaya
bca3e62ced Updated to readonly public props on as many models as possible 2022-04-23 14:00:47 +02:00
Alejandro Celaya
e79391907a Added some PHP 8.1 features 2022-04-23 13:08:21 +02:00
Alejandro Celaya
54a23cc7fa Converted EnvVars to enum 2022-04-23 12:44:17 +02:00
Alejandro Celaya
e8ebe77923 Dropped PHP 8.0 support 2022-04-23 12:08:01 +02:00
224 changed files with 3243 additions and 2038 deletions

View File

@@ -10,10 +10,10 @@ on:
jobs:
static-analysis:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.1']
command: ['cs', 'stan', 'swagger:validate']
steps:
- name: Checkout code
@@ -25,14 +25,15 @@ jobs:
tools: composer
extensions: openswoole-4.11.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- run: composer ${{ matrix.command }}
tests:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0', '8.1']
php-version: ['8.1']
test-group: ['unit', 'api']
steps:
- name: Checkout code
@@ -48,10 +49,11 @@ jobs:
extensions: openswoole-4.11.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- run: composer test:${{ matrix.test-group }}:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
if: ${{ matrix.php-version == '8.1' }}
with:
name: coverage-${{ matrix.test-group }}
path: |
@@ -59,10 +61,10 @@ jobs:
build/coverage-${{ matrix.test-group }}.cov
db-tests:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0', '8.1']
php-version: ['8.1']
platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms']
env:
LC_ALL: C
@@ -80,10 +82,11 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.0
extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Create test database
if: ${{ matrix.platform == 'ms' }}
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
@@ -91,7 +94,7 @@ jobs:
run: composer test:db:${{ matrix.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' && matrix.platform == 'sqlite:ci' }}
if: ${{ matrix.php-version == '8.1' && matrix.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |
@@ -102,10 +105,10 @@ jobs:
needs:
- tests
- db-tests
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0', '8.1']
php-version: ['8.1']
test-group: ['unit', 'db', 'api']
steps:
- name: Checkout code
@@ -118,7 +121,8 @@ jobs:
extensions: openswoole-4.11.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- uses: actions/download-artifact@v2
with:
path: build
@@ -133,10 +137,10 @@ jobs:
needs:
- tests
- db-tests
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.1']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -152,8 +156,8 @@ jobs:
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
- run: wget https://phar.phpunit.de/phpcov-8.2.0.phar
- run: php phpcov-8.2.0.phar merge build --clover build/clover.xml
- run: wget https://phar.phpunit.de/phpcov-8.2.1.phar
- run: php phpcov-8.2.1.phar merge build --clover build/clover.xml
- name: Publish coverage
uses: codecov/codecov-action@v1
with:
@@ -163,7 +167,7 @@ jobs:
needs:
- mutation-tests
- upload-coverage
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: geekyeggo/delete-artifact@v1
with:
@@ -173,7 +177,7 @@ jobs:
coverage-api
build-docker-image:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -9,7 +9,7 @@ on:
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -7,10 +7,10 @@ on:
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0', '8.1']
php-version: ['8.1']
swoole: ['yes', 'no']
steps:
- name: Checkout code
@@ -32,7 +32,7 @@ jobs:
publish:
needs: ['build']
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -50,11 +50,11 @@ jobs:
delete-artifacts:
needs: ['publish']
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: [ '8.0', '8.1' ]
swoole: [ 'yes', 'no' ]
php-version: ['8.1']
swoole: ['yes', 'no']
steps:
- uses: geekyeggo/delete-artifact@v1
with:

View File

@@ -7,10 +7,10 @@ on:
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.1']
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -4,6 +4,60 @@ 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).
## [3.2.0] - 2022-08-05
### Added
* [#854](https://github.com/shlinkio/shlink/issues/854) Added support for multi-segment custom slugs.
The feature is disabled by default, but you can optionally opt in. If you do, you will be able to create short URLs with multiple segments in the custom slug, like `https://example.com/foo/bar/baz`.
* [#1280](https://github.com/shlinkio/shlink/issues/1280) Added missing visit-related commands.
Now you can run `tag:visits`, `domain:visits`, `visit:orphan` or `visit:non-orphan` to get the corresponding list of visits from the command line.
* [#962](https://github.com/shlinkio/shlink/issues/962) Added new real-time update for new short URLs.
You can now subscribe to the `https://shlink.io/new-short-url` topic on any of the supported async updates technologies in order to get notified when a short URL is created.
* [#1367](https://github.com/shlinkio/shlink/issues/1367) Added support to publish real-time updates in redis pub/sub.
The publishing will happen in the same redis instance/cluster configured for caching.
### Changed
* [#1452](https://github.com/shlinkio/shlink/issues/1452) Updated to monolog 3
* [#1485](https://github.com/shlinkio/shlink/issues/1485) Changed payload published in RabbitMQ for all visits events, in order to conform with the Async API spec.
Since this is a breaking change, also provided a new `RABBITMQ_LEGACY_VISITS_PUBLISHING=true` env var that can be provided in order to keep the old payload.
This env var is considered deprecated and will be removed in Shlink 4, when the legacy format will no longer be supported.
### Deprecated
* *Nothing*
### Removed
* [#1280](https://github.com/shlinkio/shlink/issues/1280) Dropped support for PHP 8.0
### Fixed
* [#1471](https://github.com/shlinkio/shlink/issues/1471) Fixed error when running `visit:locate` command with any extra parameter (like `--retry`).
## [3.1.2] - 2022-06-04
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1448](https://github.com/shlinkio/shlink/issues/1448) Fixed HTML entities not being properly parsed when auto-resolving page titles.
* [#1458](https://github.com/shlinkio/shlink/issues/1458) Fixed 500 error when filtering short URLs by ALL tags and search term.
## [3.1.1] - 2022-05-09
### Added
* *Nothing*
@@ -587,7 +641,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
### Fixed
* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short RULs list.
* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short URLs list.
* [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address.
* [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods.
@@ -1235,7 +1289,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain.
Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-compaign` and `https://example.com/my-campaign`, under the same shlink instance.
Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance.
When resolving a short URL to redirect end users, the following rules are applied:
@@ -1485,7 +1539,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
### Fixed
* [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser.
* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddlware` to be always piped. Now the check is not even made, which simplifies everything.
* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddleware` to be always piped. Now the check is not even made, which simplifies everything.
## [1.15.0] - 2018-12-02
@@ -1550,7 +1604,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
### Changed
* [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase.
* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monlog's `PsrLogMessageProcessor`.
* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monolog's `PsrLogMessageProcessor`.
* [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported.
* [#196](https://github.com/shlinkio/shlink/issues/196) Reduced anemic model in entities, defining more expressive public APIs instead.
* [#249](https://github.com/shlinkio/shlink/issues/249) Added [functional-php](https://github.com/lstrojny/functional-php) to ease collections handling.

View File

@@ -1,9 +1,9 @@
FROM php:8.1.5-alpine3.15 as base
FROM php:8.1.9-alpine3.16 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV OPENSWOOLE_VERSION 4.11.1
ENV PDO_SQLSRV_VERSION 5.10.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C"

View File

@@ -35,7 +35,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.0 or 8.1
* PHP 8.1
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use openswoole.
* xml extension is required if you want to generate QR codes in svg format.
@@ -66,7 +66,9 @@ In order to run Shlink, you will need a built version of the project. There are
After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice.
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it.
> **Note**
>
> This is the process used when releasing new Shlink versions. After tagging the new version with git, the GitHub release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it.
### Configure

View File

@@ -76,7 +76,7 @@ These routes have been removed, but have a direct replacement:
* `/qr/{shortCode}[/{size}]` -> `/{shortCode}/qr-code[/{size}]`
* `PUT /rest/v{version}/short-urls/{shortCode}` -> `PATCH /rest/v{version}/short-urls/{shortCode}`
When using the old ones, a 404 status will me returned now.
When using the old ones, a 404 status will be returned now.
### Removed command and route aliases

View File

@@ -12,53 +12,48 @@
}
],
"require": {
"php": "^8.0",
"php": "^8.1",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^2.3",
"doctrine/migrations": "^3.3",
"doctrine/orm": "^2.11",
"doctrine/migrations": "^3.5",
"doctrine/orm": "^2.12",
"endroid/qr-code": "^4.4",
"geoip2/geoip2": "^2.12",
"guzzlehttp/guzzle": "^7.4",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.110",
"laminas/laminas-config": "^3.7",
"laminas/laminas-config-aggregator": "^1.7",
"laminas/laminas-diactoros": "^2.8",
"laminas/laminas-inputfilter": "^2.13",
"laminas/laminas-servicemanager": "^3.11.2",
"laminas/laminas-stdlib": "^3.6",
"laminas/laminas-config-aggregator": "^1.8",
"laminas/laminas-diactoros": "^2.14",
"laminas/laminas-inputfilter": "^2.19",
"laminas/laminas-servicemanager": "^3.16",
"laminas/laminas-stdlib": "^3.11",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.4",
"league/uri": "^6.7",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.7",
"mezzio/mezzio-fastroute": "^3.3",
"mezzio/mezzio-problem-details": "^1.5",
"mezzio/mezzio-swoole": "^4.0",
"mlocati/ip-lib": "^1.17",
"monolog/monolog": "^2.3",
"nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.11",
"pagerfanta/core": "^3.5",
"php-amqplib/php-amqplib": "^3.1",
"mezzio/mezzio": "^3.11",
"mezzio/mezzio-fastroute": "^3.5",
"mezzio/mezzio-problem-details": "^1.6",
"mezzio/mezzio-swoole": "^4.3",
"mlocati/ip-lib": "^1.18",
"ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.6",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^1.0",
"ramsey/uuid": "^4.2",
"shlinkio/shlink-common": "^4.4",
"ramsey/uuid": "^4.3",
"shlinkio/shlink-common": "^4.5",
"shlinkio/shlink-config": "^1.6",
"shlinkio/shlink-event-dispatcher": "^2.3",
"shlinkio/shlink-event-dispatcher": "^2.4",
"shlinkio/shlink-importer": "^3.0",
"shlinkio/shlink-installer": "^7.1",
"shlinkio/shlink-installer": "^8.0",
"shlinkio/shlink-ip-geolocation": "^2.2",
"symfony/console": "^6.0",
"symfony/filesystem": "^6.0",
"symfony/lock": "^6.0",
"symfony/mercure": "^0.6",
"symfony/process": "^6.0",
"symfony/string": "^6.0"
"symfony/console": "^6.1",
"symfony/filesystem": "^6.1",
"symfony/lock": "^6.1",
"symfony/process": "^6.1",
"symfony/string": "^6.1"
},
"require-dev": {
"cebe/php-openapi": "^1.7",
@@ -67,15 +62,15 @@
"infection/infection": "^0.26.5",
"openswoole/ide-helper": "~4.11.1",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^1.2",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-symfony": "^1.2",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.2.0",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^3.0.1",
"symfony/var-dumper": "^6.0",
"symfony/var-dumper": "^6.1",
"veewee/composer-run-parallel": "^1.1"
},
"autoload": {
@@ -176,7 +171,7 @@
"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 Miscrosoft SQL Server 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 reports</>",
"infect:ci": "<fg=blue;options=bold>Checks unit and db tests quality applying mutation testing with existing reports and logs</>",

View File

@@ -7,7 +7,7 @@ namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD()->loadFromEnv();
$threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD->loadFromEnv();
return [

View File

@@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
use function Functional\contains;
return (static function (): array {
$driver = EnvVars::DB_DRIVER()->loadFromEnv();
$driver = EnvVars::DB_DRIVER->loadFromEnv();
$isMysqlCompatible = contains(['maria', 'mysql'], $driver);
$resolveDriver = static fn () => match ($driver) {
@@ -35,12 +35,12 @@ return (static function (): array {
],
default => [
'driver' => $resolveDriver(),
'dbname' => EnvVars::DB_NAME()->loadFromEnv('shlink'),
'user' => EnvVars::DB_USER()->loadFromEnv(),
'password' => EnvVars::DB_PASSWORD()->loadFromEnv(),
'host' => EnvVars::DB_HOST()->loadFromEnv(EnvVars::DB_UNIX_SOCKET()->loadFromEnv()),
'port' => EnvVars::DB_PORT()->loadFromEnv($resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET()->loadFromEnv() : null,
'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'),
'user' => EnvVars::DB_USER->loadFromEnv(),
'password' => EnvVars::DB_PASSWORD->loadFromEnv(),
'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()),
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
'charset' => $resolveCharset(),
],
};

View File

@@ -9,7 +9,7 @@ return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
'license_key' => EnvVars::GEOLITE_LICENSE_KEY()->loadFromEnv(),
'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(),
],
];

View File

@@ -32,6 +32,7 @@ return [
Option\Worker\WebWorkerNumConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
Option\Mercure\MercurePublicUrlConfigOption::class,
@@ -42,6 +43,7 @@ return [
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
@@ -64,13 +66,13 @@ return [
],
'installation_commands' => [
InstallationCommand::DB_CREATE_SCHEMA => [
InstallationCommand::DB_CREATE_SCHEMA->value => [
'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME,
],
InstallationCommand::DB_MIGRATE => [
InstallationCommand::DB_MIGRATE->value => [
'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME,
],
InstallationCommand::GEOLITE_DOWNLOAD_DB => [
InstallationCommand::GEOLITE_DOWNLOAD_DB->value => [
'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME,
],
],

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Predis\ClientInterface as PredisClient;
use Shlinkio\Shlink\Common\Cache\RedisFactory;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock;
@@ -24,7 +24,7 @@ return [
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
],
'aliases' => [
'lock_store' => EnvVars::REDIS_SERVERS()->existsInEnv() ? 'redis_lock_store' : 'local_lock_store',
'lock_store' => EnvVars::REDIS_SERVERS->existsInEnv() ? 'redis_lock_store' : 'local_lock_store',
'redis_lock_store' => Lock\Store\RedisStore::class,
'local_lock_store' => Lock\Store\FlockStore::class,
@@ -38,7 +38,7 @@ return [
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Store\RedisStore::class => [PredisClient::class],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\LockFactory::class => ['lock_store'],
LOCAL_LOCK_FACTORY => ['local_lock_store'],
],

View File

@@ -4,72 +4,36 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Monolog\Formatter;
use Monolog\Handler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Processor;
use MonologFactory\DiContainerLoggerFactory;
use PhpMiddleware\RequestId;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Logger\LoggerFactory;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use const PHP_EOL;
$processors = [
'exception_with_new_line' => [
'name' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class,
],
'psr3' => [
'name' => Processor\PsrLogMessageProcessor::class,
],
'request_id' => RequestId\MonologProcessor::class,
];
$formatter = [
'name' => Formatter\LineFormatter::class,
'params' => [
'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL,
'allow_inline_line_breaks' => true,
],
$common = [
'level' => Level::Info->value,
'processors' => [RequestId\MonologProcessor::class],
'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
];
return [
'logger' => [
'Shlink' => [
'name' => 'Shlink',
'handlers' => [
'shlink_handler' => [
'name' => Handler\RotatingFileHandler::class,
'params' => [
'level' => Logger::INFO,
'filename' => 'data/log/shlink_log.log',
'max_files' => 30,
'file_permission' => 0666,
],
'formatter' => $formatter,
],
],
'processors' => $processors,
'type' => LoggerType::FILE->value,
...$common,
],
'Access' => [
'name' => 'Access',
'handlers' => [
'access_handler' => [
'name' => Handler\StreamHandler::class,
'params' => [
'level' => Logger::INFO,
'stream' => 'php://stdout',
],
'formatter' => $formatter,
],
],
'processors' => $processors,
'type' => LoggerType::STREAM->value,
...$common,
],
],
'dependencies' => [
'factories' => [
'Logger_Shlink' => [DiContainerLoggerFactory::class, 'Shlink'],
'Logger_Access' => [DiContainerLoggerFactory::class, 'Access'],
'Logger_Shlink' => [LoggerFactory::class, 'Shlink'],
'Logger_Access' => [LoggerFactory::class, 'Access'],
],
'aliases' => [
'logger' => 'Logger_Shlink',

View File

@@ -2,33 +2,18 @@
declare(strict_types=1);
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Level;
use Shlinkio\Shlink\Common\Logger\LoggerType;
$isSwoole = extension_loaded('openswoole');
// For swoole, send logs to standard output
$handler = $isSwoole
? [
'name' => StreamHandler::class,
'params' => [
'level' => Logger::DEBUG,
'stream' => 'php://stdout',
],
]
: [
'params' => [
'level' => Logger::DEBUG,
],
];
return [
'logger' => [
'Shlink' => [
'handlers' => [
'shlink_handler' => $handler,
],
// For swoole, send logs as stream
'type' => $isSwoole ? LoggerType::STREAM->value : LoggerType::FILE->value,
'level' => Level::Debug->value,
],
],

View File

@@ -9,14 +9,14 @@ use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
return (static function (): array {
$publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL()->loadFromEnv();
$publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv();
return [
'mercure' => [
'public_hub_url' => $publicUrl,
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL()->loadFromEnv($publicUrl),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET()->loadFromEnv(),
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv($publicUrl),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),
'jwt_issuer' => 'Shlink',
],

View File

@@ -13,13 +13,13 @@ use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
'qr_codes' => [
'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE()->loadFromEnv(DEFAULT_QR_CODE_SIZE),
'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN()->loadFromEnv(DEFAULT_QR_CODE_MARGIN),
'format' => EnvVars::DEFAULT_QR_CODE_FORMAT()->loadFromEnv(DEFAULT_QR_CODE_FORMAT),
'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION()->loadFromEnv(
'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(DEFAULT_QR_CODE_SIZE),
'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(DEFAULT_QR_CODE_MARGIN),
'format' => EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(DEFAULT_QR_CODE_FORMAT),
'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(
DEFAULT_QR_CODE_ERROR_CORRECTION,
),
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE()->loadFromEnv(
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(
DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
),
],

View File

@@ -2,46 +2,20 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'rabbitmq' => [
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED()->loadFromEnv(false),
'host' => EnvVars::RABBITMQ_HOST()->loadFromEnv(),
'port' => (int) EnvVars::RABBITMQ_PORT()->loadFromEnv('5672'),
'user' => EnvVars::RABBITMQ_USER()->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD()->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST()->loadFromEnv('/'),
],
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false),
'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(),
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'),
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'),
'dependencies' => [
'factories' => [
AMQPStreamConnection::class => ConfigAbstractFactory::class,
],
'delegators' => [
AMQPStreamConnection::class => [
LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [
AMQPStreamConnection::class => AMQPStreamConnection::class,
],
],
],
ConfigAbstractFactory::class => [
AMQPStreamConnection::class => [
'config.rabbitmq.host',
'config.rabbitmq.port',
'config.rabbitmq.user',
'config.rabbitmq.password',
'config.rabbitmq.vhost',
],
// Deprecated
'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false),
],
];

View File

@@ -10,14 +10,14 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT()->loadFromEnv(),
'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT()->loadFromEnv(),
'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT()->loadFromEnv(),
'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
],
'redirects' => [
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE()->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME()->loadFromEnv(
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(
DEFAULT_REDIRECT_CACHE_LIFETIME,
),
],

View File

@@ -5,17 +5,23 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$redisServers = EnvVars::REDIS_SERVERS()->loadFromEnv();
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
$pubSub = [
'redis' => [
'pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false),
],
];
return match ($redisServers) {
null => [],
null => $pubSub,
default => [
'cache' => [
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE()->loadFromEnv(),
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
],
],
...$pubSub,
],
};
})();

View File

@@ -7,12 +7,13 @@ return [
'cache' => [
'redis' => [
'servers' => 'tcp://shlink_redis:6379',
// 'servers' => [
// 'tcp://shlink_redis:6379',
// ],
],
],
'redis' => [
'pub_sub_enabled' => true,
],
'dependencies' => [
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use PhpMiddleware\RequestId;
use Shlinkio\Shlink\Common\Logger\Processor\BackwardsCompatibleMonologProcessorDelegator;
return [
@@ -20,6 +21,11 @@ return [
RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class,
RequestId\MonologProcessor::class => ConfigAbstractFactory::class,
],
'delegators' => [
RequestId\MonologProcessor::class => [
BackwardsCompatibleMonologProcessorDelegator::class,
],
],
],
ConfigAbstractFactory::class => [

View File

@@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'router' => [
'base_path' => EnvVars::BASE_PATH()->loadFromEnv(''),
'base_path' => EnvVars::BASE_PATH->loadFromEnv(''),
'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => true,

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Fig\Http\Message\RequestMethodInterface;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action as CoreAction;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Rest\Action;
use Shlinkio\Shlink\Rest\ConfigProvider;
use Shlinkio\Shlink\Rest\Middleware;
use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler;
use function sprintf;
// The order of the routes defined here matters. Changing it might cause path conflicts
return (static function (): array {
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
$multiSegment = (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false);
return [
'routes' => [
// Rest
...ConfigProvider::applyRoutesPrefix([
Action\HealthAction::getRouteDef(),
// Visits
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\DomainVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
Action\Visit\OrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
// Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
$overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$overrideDomainMiddleware,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),
Action\Tag\TagsStatsAction::getRouteDef(),
Action\Tag\DeleteTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
// Domains
Action\Domain\ListDomainsAction::getRouteDef(),
Action\Domain\DomainRedirectsAction::getRouteDef(),
Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]),
], $multiSegment),
// Non-rest
[
'name' => CoreAction\RobotsAction::class,
'path' => '/robots.txt',
'middleware' => [
CoreAction\RobotsAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\PixelAction::class,
'path' => sprintf('/{shortCode%s}/track', $multiSegment ? ':.+' : ''),
'middleware' => [
IpAddress::class,
CoreAction\PixelAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\QrCodeAction::class,
'path' => sprintf('/{shortCode%s}/qr-code', $multiSegment ? ':.+' : ''),
'middleware' => [
CoreAction\QrCodeAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\RedirectAction::class,
'path' => sprintf('/{shortCode%s}', $multiSegment ? ':.+' : ''),
'middleware' => [
IpAddress::class,
CoreAction\RedirectAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
],
];
})();

View File

@@ -7,7 +7,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
return (static function (): array {
$taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16);
$taskWorkers = (int) EnvVars::TASK_WORKER_NUM->loadFromEnv(16);
return [
@@ -17,11 +17,11 @@ return (static function (): array {
'swoole-http-server' => [
'host' => '0.0.0.0',
'port' => (int) EnvVars::PORT()->loadFromEnv(8080),
'port' => (int) EnvVars::PORT->loadFromEnv(8080),
'process-name' => 'shlink',
'options' => [
'worker_num' => (int) EnvVars::WEB_WORKER_NUM()->loadFromEnv(16),
'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16),
'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS),
],
],

View File

@@ -9,28 +9,28 @@ return [
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR()->loadFromEnv(true),
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS()->loadFromEnv(true),
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM()->loadFromEnv(),
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING()->loadFromEnv(false),
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING()->loadFromEnv(false),
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING()->loadFromEnv(false),
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING()->loadFromEnv(false),
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM()->loadFromEnv(),
'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM->loadFromEnv(),
],
];

View File

@@ -9,7 +9,7 @@ use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
return (static function (): array {
$shortCodesLength = max(
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH()->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
MIN_SHORT_CODES_LENGTH,
);
@@ -17,12 +17,13 @@ return (static function (): array {
'url_shortener' => [
'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain
'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http',
'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''),
'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv(true)) ? 'https' : 'http',
'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''),
],
'default_short_codes_length' => $shortCodesLength,
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES()->loadFromEnv(false),
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH()->loadFromEnv(false),
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false),
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
],
];

View File

@@ -6,14 +6,14 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
// Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0
return (static function (): array {
$webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv();
$webhooks = EnvVars::VISITS_WEBHOOKS->loadFromEnv();
return [
'visits_webhooks' => [
'webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
'notify_orphan_visits_to_webhooks' =>
(bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()->loadFromEnv(false),
(bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false),
],
];

View File

@@ -43,6 +43,8 @@ return (new ConfigAggregator\ConfigAggregator([
$isTestEnv
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
], 'data/cache/app_config.php', [
Core\Config\BasePathPrefixer::class,
]))->getMergedConfig();

View File

@@ -13,7 +13,7 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE()->loadFromEnv(date_default_timezone_get()));
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
// It needs to be placed here as individual config files will not be loaded once config is cached

View File

@@ -8,8 +8,7 @@ use GuzzleHttp\Client;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Level;
use PHPUnit\Runner\Version;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -20,6 +19,7 @@ use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html;
use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Config\env;
@@ -76,16 +76,10 @@ $buildDbConnection = static function (): array {
};
};
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
'handlers' => [
$handlerName => [
'name' => StreamHandler::class,
'params' => [
'level' => Logger::DEBUG,
'stream' => sprintf('data/log/api-tests/%s', $filename),
],
],
],
$buildTestLoggerConfig = static fn (string $filename) => [
'level' => Level::Debug->value,
'type' => LoggerType::STREAM->value,
'destination' => sprintf('data/log/api-tests/%s', $filename),
];
return [
@@ -183,8 +177,8 @@ return [
],
'logger' => [
'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'),
'Access' => $buildTestLoggerConfig('access_handler', 'access.log'),
'Shlink' => $buildTestLoggerConfig('shlink.log'),
'Access' => $buildTestLoggerConfig('access.log'),
],
];

View File

@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View File

@@ -1,8 +1,8 @@
FROM php:8.1.5-fpm-alpine3.15
FROM php:8.1.9-fpm-alpine3.16
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update

View File

@@ -1,10 +1,10 @@
FROM php:8.1.5-alpine3.15
FROM php:8.1.9-alpine3.16
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 4.11.1
ENV PDO_SQLSRV_VERSION 5.10.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update

View File

@@ -8,8 +8,8 @@ use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
final class Version20210207100807 extends AbstractMigration
{
@@ -27,7 +27,7 @@ final class Version20210207100807 extends AbstractMigration
]);
$visits->addColumn('type', Types::STRING, [
'length' => 255,
'default' => Visit::TYPE_VALID_SHORT_URL,
'default' => VisitType::VALID_SHORT_URL->value,
]);
}

View File

@@ -4,22 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Shlinkio\Shlink\Common\Logger\LoggerType;
return [
'logger' => [
'Shlink' => [
'handlers' => [
'shlink_handler' => [
'name' => StreamHandler::class,
'params' => [
'level' => Logger::INFO,
'stream' => 'php://stdout',
],
],
],
'type' => LoggerType::STREAM->value,
],
],

View File

@@ -16,7 +16,7 @@ The intention is to implement a system that allows adding to API keys as many of
Supporting more restrictions in the future is also desirable.
## Considered option
## Considered options
* Using an ACL/RBAC library, and checking roles in a middleware.
* Using a service that, provided an API key, tells if certain resource is reachable while it also allows building queries dynamically.

View File

@@ -11,7 +11,7 @@ However, it does not track visits to any of those, just to valid short URLs.
The intention is to change that, and allow users to track the cases mentioned above.
## Considered option
## Considered options
* Create a new table to track visits o this kind.
* Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields.

View File

@@ -13,7 +13,7 @@ However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php
Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters.
## Considered option
## Considered options
After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these:

View File

@@ -11,7 +11,7 @@ It is potentially possible to combine both, but if you do so, you will find out
A [Twitter survey](https://twitter.com/shlinkio/status/1480614855006732289) has also showed up all participants also found the behavior should be the opposite.
## Considered option
## Considered options
* Move the logic to read env vars to another config file which always overrides installer options.
* Move the logic to read env vars to a config post-processor which overrides config dynamically, only if the appropriate env var had been defined.

View File

@@ -0,0 +1,42 @@
# Support multi-segment custom slugs
* Status: Accepted
* Date: 2022-08-05
## Context and problem statement
There's a new requirement to support multi-segment custom slugs (as in `https://exam.ple/foo/bar/baz`).
The internal router does not support this at the moment, as it only matches the shortCode in one of the segments.
## Considered options
* Tweak the internal router, so that it is capable of matching multiple segments for the slug, in every route that requires it.
* Define a new set of routes with a short prefix that allows configuring multi-segment in those, without touching the existing routes.
* Let the router fail, and use a middleware to fall back to the proper route (similar to what was done for the extra path forwarding feature).
## Decision outcome
Even though I was initially inclined to use a fallback middleware, that has turned out to be harder than anticipated, because there are several possible routes where the slug is used, and we would still need some kind of router to determine which one matches.
Because of that, the selected approach has been to tweak the existing router, so that it can match multiple segments, and moving the configuration of routes to a common place so that they can be defined in the proper order that prevents conflicts.
## Pros and Cons of the Options
### Tweaking the router
* Bad: It requires routes to be defined in a specific order, and remember it in the future if more routes are added.
* Good: It initially requires fewer changes.
* Good: Once routes are defined in the proper order, all the internal logic works out of the box.
### Defining new routes
* Bad: The end-user experience gets affected.
* Bad: Probably a lot of side effects would happen when it comes to assembling short URLs.
* Bad: Routing needs to be configured twice, resolving the same logic.
* Bad: It turns out to still conflict with some routes, even with the prefix, which defeats what looked like its main benefit.
### Let routing fail and fall back in middleware
* Good: Does not require changing routes configuration, which means less side effects.
* Bad: Since many routes can potentially end up in the middleware, there's still the need to have some kind of routing logic.

View File

@@ -2,6 +2,7 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md)
* [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md)
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)

View File

@@ -1,8 +1,8 @@
{
"asyncapi": "2.0.0",
"asyncapi": "2.4.0",
"info": {
"title": "Shlink",
"version": "2.0.0",
"version": "3.0.0",
"description": "Shlink, the self-hosted URL shortener",
"license": {
"name": "MIT",
@@ -75,6 +75,23 @@
}
}
}
},
"https://shlink.io/new-short-url": {
"subscribe": {
"summary": "Receive information about any new short URL.",
"operationId": "newshortUrl",
"message": {
"payload": {
"type": "object",
"additionalProperties": false,
"properties": {
"shortUrl": {
"$ref": "#/components/schemas/ShortUrl"
}
}
}
}
}
}
},
"components": {
@@ -101,7 +118,7 @@
},
"visitsCount": {
"type": "integer",
"description": "The number of visits that this short URL has recieved."
"description": "The number of visits that this short URL has received."
},
"tags": {
"type": "array",

View File

@@ -33,7 +33,7 @@
},
"visitsCount": {
"type": "integer",
"description": "The number of visits that this short URL has recieved."
"description": "The number of visits that this short URL has received."
},
"tags": {
"type": "array",

View File

@@ -312,7 +312,7 @@
},
"threshold": {
"type": "number",
"description": "The amount of visits currently configured as threshold to allow deleting short UYRLs or not"
"description": "The amount of visits currently configured as threshold to allow deleting short URLs or not"
}
}
}

View File

@@ -53,7 +53,7 @@
{
"name": "errorCorrection",
"in": "query",
"description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
"description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
"required": false,
"schema": {
"type": "string",

View File

@@ -3,7 +3,7 @@
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
"version": "1.0"
"version": "2.0"
},
"externalDocs": {

View File

@@ -11,11 +11,13 @@ return [
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class,
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
Command\Visit\GetOrphanVisitsCommand::NAME => Command\Visit\GetOrphanVisitsCommand::class,
Command\Visit\GetNonOrphanVisitsCommand::NAME => Command\Visit\GetNonOrphanVisitsCommand::class,
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
@@ -24,9 +26,11 @@ return [
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
Command\Tag\GetTagVisitsCommand::NAME => Command\Tag\GetTagVisitsCommand::class,
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class,
Command\Domain\GetDomainVisitsCommand::NAME => Command\Domain\GetDomainVisitsCommand::class,
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,

View File

@@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
@@ -42,11 +43,13 @@ return [
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\GetOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\GetNonOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
@@ -55,12 +58,14 @@ return [
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\GetTagVisitsCommand::class => ConfigAbstractFactory::class,
Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,
],
],
@@ -77,15 +82,14 @@ return [
Command\ShortUrl\CreateShortUrlCommand::class => [
Service\UrlShortener::class,
ShortUrlStringifier::class,
'config.url_shortener.default_short_codes_length',
'config.url_shortener.domain.hostname',
UrlShortenerOptions::class,
],
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
Service\ShortUrlService::class,
ShortUrlDataTransformer::class,
],
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
@@ -94,6 +98,8 @@ return [
IpLocationResolverInterface::class,
LockFactory::class,
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
@@ -102,9 +108,11 @@ return [
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,

View File

@@ -73,13 +73,16 @@ class GenerateKeyCommand extends Command
$authorOnly,
'a',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS),
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value),
)
->addOption(
$domainOnly,
'd',
InputOption::VALUE_REQUIRED,
sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC),
sprintf(
'Adds the "%s" role to the new API key, with the domain provided.',
Role::DOMAIN_SPECIFIC->value,
),
)
->setHelp($help);
}
@@ -99,7 +102,7 @@ class GenerateKeyCommand extends Command
if (! $apiKey->isAdmin()) {
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
null,
'Roles',
);

View File

@@ -60,10 +60,10 @@ class ListKeysCommand extends Command
}
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
fn (Role $role, array $meta) =>
empty($meta)
? Role::toFriendlyName($roleName)
: sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)),
? Role::toFriendlyName($role)
: sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)),
));
return $rowData;

View File

@@ -53,7 +53,7 @@ class DomainRedirectsCommand extends Command
/** @var string[] $availableDomains */
$availableDomains = invoke(
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault),
'toString',
);
if (empty($availableDomains)) {

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
class GetDomainVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'domain:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of visits for provided domain.')
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$domain = $input->getArgument('domain');
return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->getShortUrl();
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -48,12 +48,12 @@ class ListDomainsCommand extends Command
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
map($domains, function (DomainItem $domain) use ($showRedirects) {
$commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No'];
$commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No'];
return $showRedirects
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig()),
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
]
: $commonValues;
}),

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
@@ -19,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
use function explode;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
@@ -29,14 +32,15 @@ class CreateShortUrlCommand extends Command
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
private string $defaultDomain;
public function __construct(
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength,
private string $defaultDomain,
private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlStringifierInterface $stringifier,
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->defaultDomain = $this->options->domain()['hostname'] ?? '';
}
protected function configure(): void
@@ -150,11 +154,11 @@ class CreateShortUrlCommand extends Command
return ExitCodes::EXIT_FAILURE;
}
$explodeWithComma = curry('explode')(',');
$explodeWithComma = curry(explode(...))(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->defaultShortCodeLength;
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength();
$doValidateUrl = $input->getOption('validate-url');
try {
@@ -171,6 +175,7 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled(),
]));
$io->writeln([

View File

@@ -81,6 +81,6 @@ class DeleteShortUrlCommand extends Command
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold);
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode()));
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode));
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'short-url:visits';
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
return;
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = ShortUrlIdentifier::fromCli($input);
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return [];
}
}

View File

@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommand extends AbstractWithDateRangeCommand
{
public const NAME = 'short-url:visits';
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
}
protected function getStartDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
}
protected function getEndDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
return;
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$identifier = ShortUrlIdentifier::fromCli($input);
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(buildDateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
$rowData = $visit->jsonSerialize();
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
});
ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -120,9 +121,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$tags = $input->getOption('tags');
$tagsMode = $input->getOption('including-all-tags') === true
? ShortUrlsParams::TAGS_MODE_ALL
: ShortUrlsParams::TAGS_MODE_ANY;
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
$all = $input->getOption('all');
$startDate = $this->getStartDateOption($input, $output);

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
class GetTagVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'tag:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of visits for provided tag.')
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->getShortUrl();
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -46,7 +46,7 @@ class ListTagsCommand extends Command
return map(
$tags,
static fn (TagInfo $tagInfo) => [$tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount],
);
}
}

View File

@@ -19,7 +19,7 @@ class RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
public function __construct(private TagServiceInterface $tagService)
public function __construct(private readonly TagServiceInterface $tagService)
{
parent::__construct();
}

View File

@@ -14,7 +14,7 @@ use function sprintf;
abstract class AbstractLockedCommand extends Command
{
public function __construct(private LockFactory $locker)
public function __construct(private readonly LockFactory $locker)
{
parent::__construct();
}
@@ -22,11 +22,11 @@ abstract class AbstractLockedCommand extends Command
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$lockConfig = $this->getLockConfig();
$lock = $this->locker->createLock($lockConfig->lockName(), $lockConfig->ttl(), $lockConfig->isBlocking());
$lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
if (! $lock->acquire($lockConfig->isBlocking())) {
if (! $lock->acquire($lockConfig->isBlocking)) {
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName()),
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return ExitCodes::EXIT_WARNING;
}

View File

@@ -9,9 +9,9 @@ final class LockedCommandConfig
public const DEFAULT_TTL = 600.0; // 10 minutes
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL,
public readonly string $lockName,
public readonly bool $isBlocking,
public readonly float $ttl = self::DEFAULT_TTL,
) {
}
@@ -24,19 +24,4 @@ final class LockedCommandConfig
{
return new self($lockName, false);
}
public function lockName(): string
{
return $this->lockName;
}
public function isBlocking(): bool
{
return $this->isBlocking;
}
public function ttl(): float
{
return $this->ttl;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
use function sprintf;
abstract class AbstractVisitsListCommand extends AbstractWithDateRangeCommand
{
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
final protected function getStartDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
}
final protected function getEndDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render($headers, $rows);
return ExitCodes::EXIT_SUCCESS;
}
private function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = [];
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) use (&$extraKeys) {
$extraFields = $this->mapExtraFields($visit);
$extraKeys = array_keys($extraFields);
$rowData = [
...$visit->jsonSerialize(),
'country' => $visit->getVisitLocation()?->getCountryName() ?? 'Unknown',
'city' => $visit->getVisitLocation()?->getCityName() ?? 'Unknown',
...$extraFields,
];
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
});
$extra = map($extraKeys, camelCaseToHumanFriendly(...));
return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
];
}
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**
* @return array<string, string>
*/
abstract protected function mapExtraFields(Visit $visit): array;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:non-orphan';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of non-orphan visits.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->getShortUrl();
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Symfony\Component\Console\Input\InputInterface;
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:orphan';
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of orphan visits.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return ['type' => $visit->type()->value];
}
}

View File

@@ -17,6 +17,7 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -80,12 +81,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
);
}
if ($all && $retry && ! $this->warnAndVerifyContinue($input)) {
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
throw new RuntimeException('Execution aborted');
}
}
private function warnAndVerifyContinue(InputInterface $input): bool
private function warnAndVerifyContinue(): bool
{
$this->io->warning([
'You are about to process the location of all existing visits your short URLs received.',
@@ -103,7 +104,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$all = $retry && $input->getOption('all');
try {
$this->checkDbUpdate($input);
$this->checkDbUpdate();
if ($all) {
$this->visitLocator->locateAllVisits($this);
@@ -166,7 +167,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$this->io->writeln($message);
}
private function checkDbUpdate(InputInterface $input): void
private function checkDbUpdate(): void
{
$cliApp = $this->getApplication();
if ($cliApp === null) {
@@ -174,7 +175,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
}
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run($input, $this->io);
$exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io);
if ($exitCode === ExitCodes::EXIT_FAILURE) {
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');

View File

@@ -16,7 +16,7 @@ class InvalidRoleConfigException extends InvalidArgumentException implements Exc
return new self(sprintf(
'You cannot create an API key with the "%s" role attached to the default domain. '
. 'The role is currently limited to non-default domains.',
Role::DOMAIN_SPECIFIC,
Role::DOMAIN_SPECIFIC->value,
));
}
}

View File

@@ -15,7 +15,7 @@ final class ShlinkTable
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
private function __construct(private Table $baseTable, private bool $withRowSeparators)
private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators)
{
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetDomainVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
);
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createEmpty();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$domain = 'doma.in';
$getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View File

@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@@ -38,8 +39,7 @@ class CreateShortUrlCommandTest extends TestCase
$command = new CreateShortUrlCommand(
$this->urlShortener->reveal(),
$this->stringifier->reveal(),
5,
self::DEFAULT_DOMAIN,
new UrlShortenerOptions(['defaultShortCodesLength' => 5, 'domain' => ['hostname' => self::DEFAULT_DOMAIN]]),
);
$this->commandTester = $this->testerForCommand($command);
}

View File

@@ -36,10 +36,11 @@ class DeleteShortUrlCommandTest extends TestCase
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will(
function (): void {
},
);
$deleteByShortCode = $this->service->deleteByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
false,
)->will(function (): void {
});
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@@ -55,7 +56,7 @@ class DeleteShortUrlCommandTest extends TestCase
public function invalidShortCodePrintsMessage(): void
{
$shortCode = 'abc123';
$identifier = new ShortUrlIdentifier($shortCode);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow(
Exception\ShortUrlNotFoundException::fromNotFound($identifier),
);
@@ -77,7 +78,7 @@ class DeleteShortUrlCommandTest extends TestCase
string $expectedMessage,
): void {
$shortCode = 'abc123';
$identifier = new ShortUrlIdentifier($shortCode);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will(
function (array $args) use ($shortCode): void {
$ignoreThreshold = array_pop($args);
@@ -114,12 +115,13 @@ class DeleteShortUrlCommandTest extends TestCase
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow(
Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
),
);
$deleteByShortCode = $this->service->deleteByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
false,
)->willThrow(Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
));
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['shortCode' => $shortCode]);

View File

@@ -9,7 +9,7 @@ use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -23,9 +23,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommandTest extends TestCase
class GetShortUrlVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
@@ -35,7 +36,7 @@ class GetVisitsCommandTest extends TestCase
public function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetVisitsCommand($this->visitsHelper->reveal());
$command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal());
$this->commandTester = $this->testerForCommand($command);
}
@@ -44,8 +45,8 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(DateRange::emptyInstance()),
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -60,8 +61,8 @@ class GetVisitsCommandTest extends TestCase
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))),
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(buildDateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -79,8 +80,8 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$startDate = 'foo';
$info = $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(DateRange::emptyInstance()),
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
@@ -99,19 +100,30 @@ class GetVisitsCommandTest extends TestCase
/** @test */
public function outputIsProperlyGenerated(): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')),
),
])),
$this->visitsHelper->visitsForShortUrl(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
Argument::any(),
)->willReturn(
new Paginator(new ArrayAdapter([$visit])),
)->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('foo', $output);
self::assertStringContainsString('Spain', $output);
self::assertStringContainsString('bar', $output);
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+
| Referer | Date | User agent | Country | City |
+---------+---------------------------+------------+---------+--------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid |
+---------+---------------------------+------------+---------+--------+
OUTPUT,
$output,
);
}
}

View File

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -205,23 +206,23 @@ class ListShortUrlsCommandTest extends TestCase
public function provideArgs(): iterable
{
yield [[], 1, null, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [['--page' => $page = 3], $page, null, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [['--including-all-tags' => true], 1, null, [], ShortUrlsParams::TAGS_MODE_ALL];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [[], 1, null, [], TagsMode::ANY->value];
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
yield [
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
$page,
$searchTerm,
explode(',', $tags),
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
];
yield [
['--start-date' => $startDate = '2019-01-01'],
1,
null,
[],
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
$startDate,
];
yield [
@@ -229,7 +230,7 @@ class ListShortUrlsCommandTest extends TestCase
1,
null,
[],
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
null,
$endDate,
];
@@ -238,7 +239,7 @@ class ListShortUrlsCommandTest extends TestCase
1,
null,
[],
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
$startDate,
$endDate,
];
@@ -276,7 +277,7 @@ class ListShortUrlsCommandTest extends TestCase
'page' => 1,
'searchTerm' => null,
'tags' => [],
'tagsMode' => ShortUrlsParams::TAGS_MODE_ANY,
'tagsMode' => TagsMode::ANY->value,
'startDate' => null,
'endDate' => null,
'orderBy' => null,

View File

@@ -37,8 +37,9 @@ class ResolveUrlCommandTest extends TestCase
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = ShortUrl::withLongUrl($expectedUrl);
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode))->willReturn(
$shortUrl,
)->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@@ -48,8 +49,8 @@ class ResolveUrlCommandTest extends TestCase
/** @test */
public function incorrectShortCodeOutputsErrorMessage(): void
{
$identifier = new ShortUrlIdentifier('abc123');
$shortCode = $identifier->shortCode();
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123');
$shortCode = $identifier->shortCode;
$this->urlResolver->resolveShortUrl($identifier)
->willThrow(ShortUrlNotFoundException::fromNotFound($identifier))

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetTagVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetTagVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
);
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createEmpty();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$tag = 'abc123';
$getVisits = $this->visitsHelper->visitsForTag($tag, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['tag' => $tag]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetNonOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetNonOrphanVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
);
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createEmpty();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$getVisits = $this->visitsHelper->nonOrphanVisits(Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper->reveal()));
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$getVisits = $this->visitsHelper->orphanVisits(Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+----------+
| Referer | Date | User agent | Country | City | Type |
+---------+---------------------------+------------+---------+--------+----------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | base_url |
+---------+---------------------------+------------+---------+--------+----------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
}
}

View File

@@ -20,7 +20,7 @@ class InvalidRoleConfigExceptionTest extends TestCase
self::assertEquals(sprintf(
'You cannot create an API key with the "%s" role attached to the default domain. '
. 'The role is currently limited to non-default domains.',
Role::DOMAIN_SPECIFIC,
Role::DOMAIN_SPECIFIC->value,
), $e->getMessage());
}
}

View File

@@ -27,6 +27,7 @@ return [
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
Options\QrCodeOptions::class => ConfigAbstractFactory::class,
Options\RabbitMqOptions::class => ConfigAbstractFactory::class,
Options\WebhookOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class,
@@ -63,7 +64,7 @@ return [
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
@@ -91,6 +92,7 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
Options\QrCodeOptions::class => ['config.qr_codes'],
Options\RabbitMqOptions::class => ['config.rabbitmq'],
Options\WebhookOptions::class => ['config.visits_webhooks'],
Service\UrlShortener::class => [
@@ -98,6 +100,7 @@ return [
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Service\ShortUrl\ShortCodeUniquenessHelper::class,
EventDispatcherInterface::class,
],
Visit\VisitsTracker::class => [
'em',
@@ -157,7 +160,7 @@ return [
Options\UrlShortenerOptions::class,
],
Mercure\MercureUpdatesGenerator::class => [
EventDispatcher\PublishingUpdatesGenerator::class => [
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Visit\Transformer\OrphanVisitDataTransformer::class,
],

View File

@@ -6,9 +6,11 @@ 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\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
@@ -61,10 +63,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->nullable()
->build();
$builder->createField('type', Types::STRING)
->columnName('type')
->length(255)
->build();
(new FieldBuilder($builder, [
'fieldName' => 'type',
'type' => Types::STRING,
'enumType' => VisitType::class,
]))->columnName('type')
->length(255)
->build();
$builder->createField('potentialBot', Types::BOOLEAN)
->columnName('potential_bot')

View File

@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper;
use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper;
use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Mercure\Hub;
return [
@@ -22,11 +23,17 @@ return [
],
'async' => [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\NotifyVisitToMercure::class,
EventDispatcher\NotifyVisitToRabbitMq::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
],
EventDispatcher\Event\ShortUrlCreated::class => [
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class,
],
],
],
@@ -34,16 +41,32 @@ return [
'factories' => [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
],
'delegators' => [
EventDispatcher\NotifyVisitToMercure::class => [
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToRabbitMq::class => [
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
@@ -68,18 +91,46 @@ return [
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],
EventDispatcher\NotifyVisitToMercure::class => [
Hub::class,
Mercure\MercureUpdatesGenerator::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\NotifyVisitToRabbitMq::class => [
AMQPStreamConnection::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Visit\Transformer\OrphanVisitDataTransformer::class,
'config.rabbitmq.enabled',
Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Options\RabbitMqOptions::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'],
],

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action;
return [
'routes' => [
[
'name' => Action\RobotsAction::class,
'path' => '/robots.txt',
'middleware' => [
Action\RobotsAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\RedirectAction::class,
'path' => '/{shortCode}',
'middleware' => [
IpAddress::class,
Action\RedirectAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\PixelAction::class,
'path' => '/{shortCode}/track',
'middleware' => [
IpAddress::class,
Action\PixelAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\QrCodeAction::class,
'path' => '/{shortCode}/qr-code',
'middleware' => [
Action\QrCodeAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
],
];

View File

@@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
@@ -19,6 +20,7 @@ use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
use function str_repeat;
use function ucfirst;
function generateRandomShortCode(int $length): string
{
@@ -115,3 +117,13 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll
default => $field,
};
}
function camelCaseToHumanFriendly(string $value): string
{
static $filter;
if ($filter === null) {
$filter = new CamelCaseToSeparator(' ');
}
return ucfirst($filter->filter($value));
}

View File

@@ -29,11 +29,11 @@ final class QrCodeParams
private const SUPPORTED_FORMATS = ['png', 'svg'];
private function __construct(
private int $size,
private int $margin,
private WriterInterface $writer,
private ErrorCorrectionLevelInterface $errorCorrectionLevel,
private RoundBlockSizeModeInterface $roundBlockSizeMode,
public readonly int $size,
public readonly int $margin,
public readonly WriterInterface $writer,
public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel,
public readonly RoundBlockSizeModeInterface $roundBlockSizeMode,
) {
}
@@ -105,29 +105,4 @@ final class QrCodeParams
{
return strtolower(trim($param));
}
public function size(): int
{
return $this->size;
}
public function margin(): int
{
return $this->margin;
}
public function writer(): WriterInterface
{
return $this->writer;
}
public function errorCorrectionLevel(): ErrorCorrectionLevelInterface
{
return $this->errorCorrectionLevel;
}
public function roundBlockSizeMode(): RoundBlockSizeModeInterface
{
return $this->roundBlockSizeMode;
}
}

View File

@@ -42,11 +42,11 @@ class QrCodeAction implements MiddlewareInterface
$params = QrCodeParams::fromRequest($request, $this->defaultOptions);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($params->size())
->margin($params->margin())
->writer($params->writer())
->errorCorrectionLevel($params->errorCorrectionLevel())
->roundBlockSizeMode($params->roundBlockSizeMode());
->size($params->size)
->margin($params->margin)
->writer($params->writer)
->errorCorrectionLevel($params->errorCorrectionLevel)
->roundBlockSizeMode($params->roundBlockSizeMode);
return new QrCodeResponse($qrCodeBuilder->build());
}

View File

@@ -4,153 +4,70 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use ReflectionClass;
use ReflectionClassConstant;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use function array_values;
use function Functional\contains;
use function Shlinkio\Shlink\Config\env;
// TODO Convert to enum after dropping PHP 8.0 support
/**
* @method static EnvVars DELETE_SHORT_URL_THRESHOLD()
* @method static EnvVars DB_DRIVER()
* @method static EnvVars DB_NAME()
* @method static EnvVars DB_USER()
* @method static EnvVars DB_PASSWORD()
* @method static EnvVars DB_HOST()
* @method static EnvVars DB_UNIX_SOCKET()
* @method static EnvVars DB_PORT()
* @method static EnvVars GEOLITE_LICENSE_KEY()
* @method static EnvVars REDIS_SERVERS()
* @method static EnvVars REDIS_SENTINEL_SERVICE()
* @method static EnvVars MERCURE_PUBLIC_HUB_URL()
* @method static EnvVars MERCURE_INTERNAL_HUB_URL()
* @method static EnvVars MERCURE_JWT_SECRET()
* @method static EnvVars DEFAULT_QR_CODE_SIZE()
* @method static EnvVars DEFAULT_QR_CODE_MARGIN()
* @method static EnvVars DEFAULT_QR_CODE_FORMAT()
* @method static EnvVars DEFAULT_QR_CODE_ERROR_CORRECTION()
* @method static EnvVars DEFAULT_QR_CODE_ROUND_BLOCK_SIZE()
* @method static EnvVars RABBITMQ_ENABLED()
* @method static EnvVars RABBITMQ_HOST()
* @method static EnvVars RABBITMQ_PORT()
* @method static EnvVars RABBITMQ_USER()
* @method static EnvVars RABBITMQ_PASSWORD()
* @method static EnvVars RABBITMQ_VHOST()
* @method static EnvVars DEFAULT_INVALID_SHORT_URL_REDIRECT()
* @method static EnvVars DEFAULT_REGULAR_404_REDIRECT()
* @method static EnvVars DEFAULT_BASE_URL_REDIRECT()
* @method static EnvVars REDIRECT_STATUS_CODE()
* @method static EnvVars REDIRECT_CACHE_LIFETIME()
* @method static EnvVars BASE_PATH()
* @method static EnvVars PORT()
* @method static EnvVars TASK_WORKER_NUM()
* @method static EnvVars WEB_WORKER_NUM()
* @method static EnvVars ANONYMIZE_REMOTE_ADDR()
* @method static EnvVars TRACK_ORPHAN_VISITS()
* @method static EnvVars DISABLE_TRACK_PARAM()
* @method static EnvVars DISABLE_TRACKING()
* @method static EnvVars DISABLE_IP_TRACKING()
* @method static EnvVars DISABLE_REFERRER_TRACKING()
* @method static EnvVars DISABLE_UA_TRACKING()
* @method static EnvVars DISABLE_TRACKING_FROM()
* @method static EnvVars DEFAULT_SHORT_CODES_LENGTH()
* @method static EnvVars IS_HTTPS_ENABLED()
* @method static EnvVars DEFAULT_DOMAIN()
* @method static EnvVars AUTO_RESOLVE_TITLES()
* @method static EnvVars REDIRECT_APPEND_EXTRA_PATH()
* @method static EnvVars TIMEZONE()
* @method static EnvVars VISITS_WEBHOOKS()
* @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()
*/
final class EnvVars
enum EnvVars: string
{
public const DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD';
public const DB_DRIVER = 'DB_DRIVER';
public const DB_NAME = 'DB_NAME';
public const DB_USER = 'DB_USER';
public const DB_PASSWORD = 'DB_PASSWORD';
public const DB_HOST = 'DB_HOST';
public const DB_UNIX_SOCKET = 'DB_UNIX_SOCKET';
public const DB_PORT = 'DB_PORT';
public const GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY';
public const REDIS_SERVERS = 'REDIS_SERVERS';
public const REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
public const MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
public const MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
public const MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
public const DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
public const DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
public const DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
public const DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
public const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
public const RABBITMQ_ENABLED = 'RABBITMQ_ENABLED';
public const RABBITMQ_HOST = 'RABBITMQ_HOST';
public const RABBITMQ_PORT = 'RABBITMQ_PORT';
public const RABBITMQ_USER = 'RABBITMQ_USER';
public const RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD';
public const RABBITMQ_VHOST = 'RABBITMQ_VHOST';
public const DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
public const DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
public const DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';
public const REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE';
public const REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
public const BASE_PATH = 'BASE_PATH';
public const PORT = 'PORT';
public const TASK_WORKER_NUM = 'TASK_WORKER_NUM';
public const WEB_WORKER_NUM = 'WEB_WORKER_NUM';
public const ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
public const TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
public const DISABLE_TRACKING = 'DISABLE_TRACKING';
public const DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING';
public const DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING';
public const DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING';
public const DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM';
public const DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH';
public const IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED';
public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
public const TIMEZONE = 'TIMEZONE';
case DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD';
case DB_DRIVER = 'DB_DRIVER';
case DB_NAME = 'DB_NAME';
case DB_USER = 'DB_USER';
case DB_PASSWORD = 'DB_PASSWORD';
case DB_HOST = 'DB_HOST';
case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET';
case DB_PORT = 'DB_PORT';
case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY';
case REDIS_SERVERS = 'REDIS_SERVERS';
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case RABBITMQ_ENABLED = 'RABBITMQ_ENABLED';
case RABBITMQ_HOST = 'RABBITMQ_HOST';
case RABBITMQ_PORT = 'RABBITMQ_PORT';
case RABBITMQ_USER = 'RABBITMQ_USER';
case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD';
case RABBITMQ_VHOST = 'RABBITMQ_VHOST';
/** @deprecated */
public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING';
case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';
case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE';
case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
case BASE_PATH = 'BASE_PATH';
case PORT = 'PORT';
case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
case DISABLE_TRACKING = 'DISABLE_TRACKING';
case DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING';
case DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING';
case DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING';
case DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM';
case DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH';
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 TIMEZONE = 'TIMEZONE';
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
/** @deprecated */
public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
/**
* @return string[]
*/
public static function cases(): array
{
static $constants;
if ($constants !== null) {
return $constants;
}
$ref = new ReflectionClass(self::class);
return $constants = array_values($ref->getConstants(ReflectionClassConstant::IS_PUBLIC));
}
private function __construct(private string $envVar)
{
}
public static function __callStatic(string $name, array $arguments): self
{
if (! contains(self::cases(), $name)) {
throw new InvalidArgumentException('Invalid env var: "' . $name . '"');
}
return new self($name);
}
case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
/** @deprecated */
case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
public function loadFromEnv(mixed $default = null): mixed
{
return env($this->envVar, $default);
return env($this->value, $default);
}
public function existsInEnv(): bool

View File

@@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function Functional\compose;
use function Functional\id;
use function str_replace;
use function urlencode;
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
{
@@ -71,10 +73,10 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
$replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier),
);
$replacePlaceholdersInPath = compose(
$replacePlaceholders('\Functional\id'),
static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), // Fix duplicated bars
$replacePlaceholders(id(...)),
static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path),
);
$replacePlaceholdersInQuery = $replacePlaceholders('\urlencode');
$replacePlaceholdersInQuery = $replacePlaceholders(urlencode(...));
return $redirectUri
->withPath($replacePlaceholdersInPath($redirectUri->getPath()))

View File

@@ -9,9 +9,9 @@ use JsonSerializable;
final class NotFoundRedirects implements JsonSerializable
{
private function __construct(
private ?string $baseUrlRedirect,
private ?string $regular404Redirect,
private ?string $invalidShortUrlRedirect,
public readonly ?string $baseUrlRedirect,
public readonly ?string $regular404Redirect,
public readonly ?string $invalidShortUrlRedirect,
) {
}
@@ -33,21 +33,6 @@ final class NotFoundRedirects implements JsonSerializable
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
}
public function baseUrlRedirect(): ?string
{
return $this->baseUrlRedirect;
}
public function regular404Redirect(): ?string
{
return $this->regular404Redirect;
}
public function invalidShortUrlRedirect(): ?string
{
return $this->invalidShortUrlRedirect;
}
public function jsonSerialize(): array
{
return [

View File

@@ -12,9 +12,9 @@ use Shlinkio\Shlink\Core\Entity\Domain;
final class DomainItem implements JsonSerializable
{
private function __construct(
private string $authority,
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
private bool $isDefault,
private readonly string $authority,
public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig,
public readonly bool $isDefault,
) {
}
@@ -23,9 +23,9 @@ final class DomainItem implements JsonSerializable
return new self($domain->getAuthority(), $domain, false);
}
public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self
public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self
{
return new self($authority, $config, true);
return new self($defaultDomain, $config, true);
}
public function jsonSerialize(): array
@@ -41,14 +41,4 @@ final class DomainItem implements JsonSerializable
{
return $this->authority;
}
public function isDefault(): bool
{
return $this->isDefault;
}
public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface
{
return $this->notFoundRedirectConfig;
}
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Domain\Spec\IsDomain;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -77,10 +76,9 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
// ShortUrl is the root entity. Here, the Domain is the root entity.
// Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible.
yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) {
yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) {
Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))],
Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)],
default => [null, Spec::andX()],
}) ?? [];
}
}

View File

@@ -66,8 +66,8 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
{
$this->baseUrlRedirect = $redirects->baseUrlRedirect();
$this->regular404Redirect = $redirects->regular404Redirect();
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect();
$this->baseUrlRedirect = $redirects->baseUrlRedirect;
$this->regular404Redirect = $redirects->regular404Redirect;
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect;
}
}

View File

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -174,7 +175,7 @@ class ShortUrl extends AbstractEntity
{
/** @var Selectable $visits */
$visits = $this->visits;
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED))
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED))
->orderBy(['id' => 'DESC'])
->setMaxResults(1);

View File

@@ -10,30 +10,24 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function Shlinkio\Shlink\Core\isCrawler;
class Visit extends AbstractEntity implements JsonSerializable
{
public const TYPE_VALID_SHORT_URL = 'valid_short_url';
public const TYPE_IMPORTED = 'imported';
public const TYPE_INVALID_SHORT_URL = 'invalid_short_url';
public const TYPE_BASE_URL = 'base_url';
public const TYPE_REGULAR_404 = 'regular_404';
private string $referer;
private Chronos $date;
private ?string $remoteAddr = null;
private ?string $visitedUrl = null;
private string $userAgent;
private string $type;
private VisitType $type;
private ?ShortUrl $shortUrl;
private ?VisitLocation $visitLocation = null;
private bool $potentialBot;
private function __construct(?ShortUrl $shortUrl, string $type)
private function __construct(?ShortUrl $shortUrl, VisitType $type)
{
$this->shortUrl = $shortUrl;
$this->date = Chronos::now();
@@ -42,7 +36,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
{
$instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL);
$instance = new self($shortUrl, VisitType::VALID_SHORT_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -50,7 +44,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self
{
$instance = new self($shortUrl, self::TYPE_IMPORTED);
$instance = new self($shortUrl, VisitType::IMPORTED);
$instance->userAgent = $importedVisit->userAgent();
$instance->potentialBot = isCrawler($instance->userAgent);
$instance->referer = $importedVisit->referer();
@@ -64,7 +58,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_BASE_URL);
$instance = new self(null, VisitType::BASE_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -72,7 +66,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_INVALID_SHORT_URL);
$instance = new self(null, VisitType::INVALID_SHORT_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -80,7 +74,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_REGULAR_404);
$instance = new self(null, VisitType::REGULAR_404);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -88,10 +82,10 @@ class Visit extends AbstractEntity implements JsonSerializable
private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void
{
$this->userAgent = $visitor->getUserAgent();
$this->referer = $visitor->getReferer();
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
$this->visitedUrl = $visitor->getVisitedUrl();
$this->userAgent = $visitor->userAgent;
$this->referer = $visitor->referer;
$this->remoteAddr = $this->processAddress($anonymize, $visitor->remoteAddress);
$this->visitedUrl = $visitor->visitedUrl;
$this->potentialBot = $visitor->isPotentialBot();
}
@@ -124,7 +118,7 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->shortUrl;
}
public function getVisitLocation(): ?VisitLocationInterface
public function getVisitLocation(): ?VisitLocation
{
return $this->visitLocation;
}
@@ -150,7 +144,7 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->visitedUrl;
}
public function type(): string
public function type(): VisitType
{
return $this->type;
}
@@ -159,11 +153,19 @@ class Visit extends AbstractEntity implements JsonSerializable
* Needed only for ArrayCollections to be able to apply criteria filtering
* @internal
*/
public function getType(): string
public function getType(): VisitType
{
return $this->type();
}
/**
* @internal
*/
public function getDate(): Chronos
{
return $this->date;
}
public function jsonSerialize(): array
{
return [
@@ -174,12 +176,4 @@ class Visit extends AbstractEntity implements JsonSerializable
'potentialBot' => $this->potentialBot,
];
}
/**
* @internal
*/
public function getDate(): Chronos
{
return $this->date;
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class VisitLocation extends AbstractEntity implements VisitLocationInterface
class VisitLocation extends AbstractEntity implements JsonSerializable
{
private string $countryCode;
private string $countryName;

View File

@@ -7,27 +7,27 @@ namespace Shlinkio\Shlink\Core\ErrorHandler\Model;
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use function rtrim;
class NotFoundType
{
private function __construct(private string $type)
private function __construct(private readonly ?VisitType $type)
{
}
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
{
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null);
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
$type = match (true) {
$isBaseUrl => Visit::TYPE_BASE_URL,
$routeResult->isFailure() => Visit::TYPE_REGULAR_404,
$routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL,
default => self::class,
$isBaseUrl => VisitType::BASE_URL,
$routeResult->isFailure() => VisitType::REGULAR_404,
$routeResult->getMatchedRouteName() === RedirectAction::class => VisitType::INVALID_SHORT_URL,
default => null,
};
return new self($type);
@@ -35,16 +35,16 @@ class NotFoundType
public function isBaseUrl(): bool
{
return $this->type === Visit::TYPE_BASE_URL;
return $this->type === VisitType::BASE_URL;
}
public function isRegularNotFound(): bool
{
return $this->type === Visit::TYPE_REGULAR_404;
return $this->type === VisitType::REGULAR_404;
}
public function isInvalidShortUrl(): bool
{
return $this->type === Visit::TYPE_INVALID_SHORT_URL;
return $this->type === VisitType::INVALID_SHORT_URL;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
abstract class AbstractAsyncListener
{
abstract protected function isEnabled(): bool;
abstract protected function getRemoteSystem(): RemoteSystem;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Throwable;
abstract class AbstractNotifyNewShortUrlListener extends AbstractAsyncListener
{
public function __construct(
private readonly PublishingHelperInterface $publishingHelper,
private readonly PublishingUpdatesGeneratorInterface $updatesGenerator,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(ShortUrlCreated $shortUrlCreated): void
{
if (! $this->isEnabled()) {
return;
}
$shortUrlId = $shortUrlCreated->shortUrlId;
$shortUrl = $this->em->find(ShortUrl::class, $shortUrlId);
$name = $this->getRemoteSystem()->value;
if ($shortUrl === null) {
$this->logger->warning(
'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.',
['shortUrlId' => $shortUrlId, 'name' => $name],
);
return;
}
try {
$this->publishingHelper->publishUpdate($this->updatesGenerator->newShortUrlUpdate($shortUrl));
} catch (Throwable $e) {
$this->logger->debug(
'Error while trying to notify {name} with new short URL. {e}',
['e' => $e, 'name' => $name],
);
}
}
}

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