mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-01 20:53:14 +08:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51de4b17c0 | ||
|
|
615b443652 | ||
|
|
4b7b530f49 | ||
|
|
fa7969c746 | ||
|
|
aef04af4f0 | ||
|
|
f118ea252c | ||
|
|
d514f39a82 | ||
|
|
e17556a7ae | ||
|
|
d79f11eeb8 | ||
|
|
1ec950ee1e | ||
|
|
14ba9fd6a4 | ||
|
|
83e8801827 | ||
|
|
be822646e4 | ||
|
|
3a4a27a60c | ||
|
|
1773e6ecae | ||
|
|
a8e4b2fceb | ||
|
|
15b53ef43c | ||
|
|
11a4702b10 | ||
|
|
6b15cd6d51 | ||
|
|
00169a5729 | ||
|
|
94702791d9 | ||
|
|
447cccacdf | ||
|
|
0413399102 | ||
|
|
9afc7876c4 | ||
|
|
187c17319a | ||
|
|
7310ecd886 | ||
|
|
620cd92d11 | ||
|
|
f9658c8da1 | ||
|
|
613b1d3045 | ||
|
|
d39711ec51 | ||
|
|
69dcab96f8 | ||
|
|
d76c96ad41 | ||
|
|
133efff2cd | ||
|
|
c10f0db170 | ||
|
|
037cd8a389 | ||
|
|
1d24750f43 | ||
|
|
b52ceaff9a | ||
|
|
6b0b52853c | ||
|
|
64d7ac7093 | ||
|
|
b9ba1246d4 | ||
|
|
7f9dc10f6a | ||
|
|
a1afc90150 | ||
|
|
df94c68e2e | ||
|
|
65ea1e00a6 | ||
|
|
5bccdded8a | ||
|
|
8917ed5c2e | ||
|
|
fabc752398 | ||
|
|
38d8086516 | ||
|
|
ae0ff5f23c | ||
|
|
7c659699f3 | ||
|
|
9e6cdcb838 | ||
|
|
7e2f755dfd | ||
|
|
ce2ed237c7 | ||
|
|
626caa4afa | ||
|
|
f4a7712ded | ||
|
|
bab6a3951e | ||
|
|
f49d98f2ea | ||
|
|
1312ea61f4 | ||
|
|
8d90661d0a | ||
|
|
b6b2530cb6 | ||
|
|
e4f66b7ce6 | ||
|
|
4b52c92e97 | ||
|
|
76c42bc17c | ||
|
|
c4f8da5f02 | ||
|
|
80bdeb280a | ||
|
|
99010b6eae | ||
|
|
b2dabf06bf | ||
|
|
67ae05f473 | ||
|
|
fb4fecf411 | ||
|
|
c855f011d1 | ||
|
|
02717eb2fb | ||
|
|
de70ebe769 | ||
|
|
c6109fd396 | ||
|
|
83584a3175 | ||
|
|
f5dcc52b3b | ||
|
|
1901964de1 | ||
|
|
80e9c2452b | ||
|
|
5ad4b39160 | ||
|
|
89b73a9cfa | ||
|
|
e2d8334d69 | ||
|
|
9b16d7acc0 | ||
|
|
6836840746 | ||
|
|
4084d301ca | ||
|
|
added21b18 | ||
|
|
8cd77391cc | ||
|
|
05ebfccc63 | ||
|
|
cb3a690294 | ||
|
|
194a7b0e57 | ||
|
|
98e4d01feb | ||
|
|
c22e3895b5 | ||
|
|
9a76c19615 | ||
|
|
59fa088975 | ||
|
|
163244f40f | ||
|
|
a89b53af4f |
10
.github/ISSUE_TEMPLATE/Bug.yml
vendored
10
.github/ISSUE_TEMPLATE/Bug.yml
vendored
@@ -61,7 +61,11 @@ body:
|
||||
label: Minimum steps to reproduce
|
||||
value: |
|
||||
<!--
|
||||
Emphasis in MINIMUM: What is the simplest way to reproduce the bug?
|
||||
Avoid things like "Create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause and the bug may be closed as "not reproducible".
|
||||
If you can provide a simple docker compose config, that's even better.
|
||||
Simple but detailed way to reproduce the bug:
|
||||
|
||||
* Avoid things like "create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause.
|
||||
* Avoid too vague steps or one-liners like "Update from v1 to v2".
|
||||
* Providing the reproduction in the form of a self-contained docker-composer is desirable.
|
||||
|
||||
Failing in any of these will cause the issue to be closed as "not reproducible".
|
||||
-->
|
||||
|
||||
2
.github/actions/ci-setup/action.yml
vendored
2
.github/actions/ci-setup/action.yml
vendored
@@ -44,5 +44,5 @@ runs:
|
||||
ini-values: pcov.directory=module
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install-deps == 'yes' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }}
|
||||
shell: bash
|
||||
|
||||
9
.github/workflows/ci-db-tests.yml
vendored
9
.github/workflows/ci-db-tests.yml
vendored
@@ -10,10 +10,11 @@ on:
|
||||
|
||||
jobs:
|
||||
db-tests:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3']
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
@@ -31,12 +32,12 @@ jobs:
|
||||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||
- name: Create test database
|
||||
if: ${{ inputs.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;"
|
||||
run: docker compose exec -T shlink_db_ms /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- name: Run tests
|
||||
run: composer test:db:${{ inputs.platform }}
|
||||
- name: Upload code coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
|
||||
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
|
||||
with:
|
||||
name: coverage-db
|
||||
path: |
|
||||
|
||||
2
.github/workflows/ci-docker-image-build.yml
vendored
2
.github/workflows/ci-docker-image-build.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
7
.github/workflows/ci-tests.yml
vendored
7
.github/workflows/ci-tests.yml
vendored
@@ -10,10 +10,11 @@ on:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3']
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
@@ -33,7 +34,7 @@ jobs:
|
||||
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
|
||||
- run: composer test:${{ inputs.test-group }}:ci
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.php-version == '8.2' }}
|
||||
if: ${{ matrix.php-version == '8.3' }}
|
||||
with:
|
||||
name: coverage-${{ inputs.test-group }}
|
||||
path: |
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -24,10 +24,10 @@ on:
|
||||
|
||||
jobs:
|
||||
static-analysis:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
php-version: ['8.3']
|
||||
command: ['cs', 'stan', 'swagger:validate']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
- api-tests
|
||||
- cli-tests
|
||||
- db-tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
php-version: ['8.3']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
delete-artifacts:
|
||||
needs:
|
||||
- upload-coverage
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v2
|
||||
with:
|
||||
|
||||
2
.github/workflows/publish-docker-image.yml
vendored
2
.github/workflows/publish-docker-image.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- runtime: 'rr'
|
||||
tag-suffix: 'roadrunner'
|
||||
platforms: 'linux/arm64/v8,linux/amd64'
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
image-name: shlinkio/shlink
|
||||
|
||||
8
.github/workflows/publish-release.yml
vendored
8
.github/workflows/publish-release.yml
vendored
@@ -7,10 +7,10 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3']
|
||||
php-version: ['8.2', '8.3'] # TODO 8.4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
needs: ['build']
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
delete-artifacts:
|
||||
needs: ['publish']
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v2
|
||||
with:
|
||||
|
||||
2
.github/workflows/publish-swagger-spec.yml
vendored
2
.github/workflows/publish-swagger-spec.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
|
||||
105
CHANGELOG.md
105
CHANGELOG.md
@@ -4,6 +4,106 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.2.3] - 2024-10-17
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2225](https://github.com/shlinkio/shlink/issues/2225) Fix regression introduced in v4.2.2, making config options with `null` value to be promoted as env vars with value `''`, instead of being skipped.
|
||||
|
||||
|
||||
## [4.2.2] - 2024-10-14
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [#2208](https://github.com/shlinkio/shlink/issues/2208) Explicitly promote installer config options as env vars, instead of as a side effect of loading the app config.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2213](https://github.com/shlinkio/shlink/issues/2213) Fix spaces being replaced with underscores in query parameter names, when forwarded from short URL to long URL.
|
||||
* [#2217](https://github.com/shlinkio/shlink/issues/2217) Fix docker image tag suffix being leaked to the version set inside Shlink, producing invalid SemVer version patterns.
|
||||
* [#2212](https://github.com/shlinkio/shlink/issues/2212) Fix env vars read in docker entry point not properly falling back to their `_FILE` suffixed counterpart.
|
||||
|
||||
|
||||
## [4.2.1] - 2024-10-04
|
||||
### Added
|
||||
* [#2183](https://github.com/shlinkio/shlink/issues/2183) Redis database index to be used can now be specified in the connection URI path, and Shlink will honor it.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2201](https://github.com/shlinkio/shlink/issues/2201) Fix `MEMORY_LIMIT` option being ignored when provided via installer options.
|
||||
|
||||
|
||||
## [4.2.0] - 2024-08-11
|
||||
### Added
|
||||
* [#2120](https://github.com/shlinkio/shlink/issues/2120) Add new IP address condition for the dynamic rules redirections system.
|
||||
|
||||
The conditions allow you to define IP addresses to match as static IP (1.2.3.4), CIDR block (192.168.1.0/24) or wildcard pattern (1.2.\*.\*).
|
||||
|
||||
* [#2018](https://github.com/shlinkio/shlink/issues/2018) Add option to allow all short URLs to be unconditionally crawlable in robots.txt, via `ROBOTS_ALLOW_ALL_SHORT_URLS=true` env var, or config option.
|
||||
* [#2109](https://github.com/shlinkio/shlink/issues/2109) Add option to customize user agents robots.txt, via `ROBOTS_USER_AGENTS=foo,bar,baz` env var, or config option.
|
||||
* [#2163](https://github.com/shlinkio/shlink/issues/2163) Add `short-urls:edit` command to edit existing short URLs.
|
||||
|
||||
This brings CLI and API interfaces capabilities closer, and solves an overlook since the feature was implemented years ago.
|
||||
|
||||
* [#2164](https://github.com/shlinkio/shlink/pull/2164) Add missing `--title` option to `short-url:create` and `short-url:edit` commands.
|
||||
|
||||
### Changed
|
||||
* [#2096](https://github.com/shlinkio/shlink/issues/2096) Update to RoadRunner 2024.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.1.1] - 2024-05-23
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* Use new reusable workflow to publish docker image
|
||||
* [#2015](https://github.com/shlinkio/shlink/issues/2015) Update to PHPUnit 11.
|
||||
* [#2130](https://github.com/shlinkio/shlink/pull/2130) Replace deprecated `pugx/shortid-php` package with `hidehalo/nanoid-php`.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2111](https://github.com/shlinkio/shlink/issues/2111) Fix typo in OAS docs examples where redirect rules with `query-param` condition type were defined as `query`.
|
||||
* [#2129](https://github.com/shlinkio/shlink/issues/2129) Fix error when resolving title for sites not using UTF-8 charset (detected with Japanese charsets).
|
||||
|
||||
|
||||
## [4.1.0] - 2024-04-14
|
||||
### Added
|
||||
* [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit.
|
||||
@@ -824,3 +924,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## Older versions
|
||||
* [2.x.x](docs/changelog-archive/CHANGELOG-2.x.md)
|
||||
* [1.x.x](docs/changelog-archive/CHANGELOG-1.x.md)
|
||||
|
||||
@@ -22,7 +22,7 @@ echo 'Starting server...'
|
||||
-o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
|
||||
sleep 2 # Let's give the server a couple of seconds to start
|
||||
|
||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --testdox-summary $*
|
||||
TESTS_EXIT_CODE=$?
|
||||
|
||||
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w .
|
||||
|
||||
@@ -16,19 +16,21 @@
|
||||
"ext-curl": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.1",
|
||||
"cakephp/chronos": "^3.0.2",
|
||||
"doctrine/dbal": "^4.0",
|
||||
"doctrine/dbal": "^4.1",
|
||||
"doctrine/migrations": "^3.6",
|
||||
"doctrine/orm": "^3.0",
|
||||
"doctrine/orm": "^3.2",
|
||||
"endroid/qr-code": "^5.0",
|
||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||
"geoip2/geoip2": "^3.0",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"hidehalo/nanoid-php": "^1.1",
|
||||
"jaybizzle/crawler-detect": "^1.2.116",
|
||||
"laminas/laminas-config": "^3.8",
|
||||
"laminas/laminas-config-aggregator": "^1.13",
|
||||
"laminas/laminas-config-aggregator": "^1.15",
|
||||
"laminas/laminas-diactoros": "^3.3",
|
||||
"laminas/laminas-inputfilter": "^2.27",
|
||||
"laminas/laminas-servicemanager": "^3.21",
|
||||
@@ -40,20 +42,19 @@
|
||||
"mlocati/ip-lib": "^1.18",
|
||||
"mobiledetect/mobiledetectlib": "^4.8",
|
||||
"pagerfanta/core": "^3.8",
|
||||
"pugx/shortid-php": "^1.1",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.1.1",
|
||||
"shlinkio/shlink-common": "^6.1",
|
||||
"shlinkio/shlink-config": "^3.0",
|
||||
"shlinkio/shlink-common": "^6.3",
|
||||
"shlinkio/shlink-config": "^3.2.1",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.1",
|
||||
"shlinkio/shlink-importer": "^5.3.2",
|
||||
"shlinkio/shlink-installer": "^9.1",
|
||||
"shlinkio/shlink-installer": "^9.2",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.0",
|
||||
"shlinkio/shlink-json": "^1.1",
|
||||
"spiral/roadrunner": "^2023.3",
|
||||
"spiral/roadrunner": "^2024.1",
|
||||
"spiral/roadrunner-cli": "^2.6",
|
||||
"spiral/roadrunner-http": "^3.3",
|
||||
"spiral/roadrunner-jobs": "^4.3",
|
||||
"spiral/roadrunner-http": "^3.5",
|
||||
"spiral/roadrunner-jobs": "^4.5",
|
||||
"symfony/console": "^7.0",
|
||||
"symfony/filesystem": "^7.0",
|
||||
"symfony/lock": "^7.0",
|
||||
@@ -63,13 +64,13 @@
|
||||
"require-dev": {
|
||||
"devizzent/cebe-php-openapi": "^1.0.1",
|
||||
"devster/ubench": "^2.1",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpstan/phpstan-doctrine": "^1.3",
|
||||
"phpstan/phpstan-phpunit": "^1.3",
|
||||
"phpstan/phpstan-symfony": "^1.3",
|
||||
"phpunit/php-code-coverage": "^10.1",
|
||||
"phpunit/phpcov": "^9.0",
|
||||
"phpunit/phpunit": "^10.4",
|
||||
"phpstan/phpstan": "^1.11",
|
||||
"phpstan/phpstan-doctrine": "^1.4",
|
||||
"phpstan/phpstan-phpunit": "^1.4",
|
||||
"phpstan/phpstan-symfony": "^1.4",
|
||||
"phpunit/php-code-coverage": "^11.0",
|
||||
"phpunit/phpcov": "^10.0",
|
||||
"phpunit/phpunit": "^11.3",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^4.1",
|
||||
@@ -113,16 +114,16 @@
|
||||
],
|
||||
"cs": "phpcs -s",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config module/*/migrations config docker/config --level=8",
|
||||
"stan": "APP_ENV=test php vendor/bin/phpstan analyse",
|
||||
"test": [
|
||||
"@parallel test:unit test:db",
|
||||
"@parallel test:api test:cli"
|
||||
],
|
||||
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --colors=always --testdox",
|
||||
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --testdox --testdox-summary",
|
||||
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov",
|
||||
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
|
||||
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov",
|
||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite -- $*",
|
||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite -- $*",
|
||||
@@ -135,7 +136,7 @@
|
||||
"test:api:mssql": "DB_DRIVER=mssql composer test:api -- $*",
|
||||
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov",
|
||||
"test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov",
|
||||
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml",
|
||||
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-cli.xml",
|
||||
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov",
|
||||
"test:cli:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov",
|
||||
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
|
||||
|
||||
@@ -6,7 +6,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
return (static function (): array {
|
||||
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
|
||||
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false)];
|
||||
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv()];
|
||||
$cacheRedisBlock = $redisServers === null ? [] : [
|
||||
'redis' => [
|
||||
'servers' => $redisServers,
|
||||
@@ -16,7 +16,7 @@ return (static function (): array {
|
||||
|
||||
return [
|
||||
'cache' => [
|
||||
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv('Shlink'),
|
||||
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv(),
|
||||
...$cacheRedisBlock,
|
||||
],
|
||||
'redis' => $redis,
|
||||
|
||||
@@ -23,11 +23,6 @@ return (static function (): array {
|
||||
$value = $envVar->loadFromEnv();
|
||||
return $value === null ? null : (string) $value;
|
||||
};
|
||||
$resolveDefaultPort = static fn () => match ($driver) {
|
||||
'postgres' => '5432',
|
||||
'mssql' => '1433',
|
||||
default => '3306',
|
||||
};
|
||||
$resolveCharset = static fn () => match ($driver) {
|
||||
// This does not determine charsets or collations in tables or columns, but the charset used in the data
|
||||
// flowing in the connection, so it has to match what has been set in the database.
|
||||
@@ -43,11 +38,11 @@ return (static function (): array {
|
||||
],
|
||||
default => [
|
||||
'driver' => $resolveDriver(),
|
||||
'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'),
|
||||
'dbname' => EnvVars::DB_NAME->loadFromEnv(),
|
||||
'user' => $readCredentialAsString(EnvVars::DB_USER),
|
||||
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD),
|
||||
'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()),
|
||||
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
|
||||
'host' => EnvVars::DB_HOST->loadFromEnv(),
|
||||
'port' => EnvVars::DB_PORT->loadFromEnv(),
|
||||
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
|
||||
'charset' => $resolveCharset(),
|
||||
'driverOptions' => $driver !== 'mssql' ? [] : [
|
||||
|
||||
@@ -45,6 +45,8 @@ return [
|
||||
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
||||
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
||||
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
||||
Option\Robots\RobotsAllowAllShortUrlsConfigOption::class,
|
||||
Option\Robots\RobotsUserAgentsConfigOption::class,
|
||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||
|
||||
@@ -7,7 +7,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
return [
|
||||
|
||||
'matomo' => [
|
||||
'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false),
|
||||
'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(),
|
||||
'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(),
|
||||
'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(),
|
||||
'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(),
|
||||
|
||||
@@ -15,7 +15,7 @@ return (static function (): array {
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => $publicUrl,
|
||||
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv($publicUrl),
|
||||
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv(),
|
||||
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),
|
||||
'jwt_issuer' => 'Shlink',
|
||||
],
|
||||
|
||||
@@ -4,32 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
|
||||
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(
|
||||
DEFAULT_QR_CODE_ERROR_CORRECTION,
|
||||
),
|
||||
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(
|
||||
DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
|
||||
),
|
||||
'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(
|
||||
DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
|
||||
),
|
||||
'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(DEFAULT_QR_CODE_COLOR),
|
||||
'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(DEFAULT_QR_CODE_BG_COLOR),
|
||||
'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(),
|
||||
'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(),
|
||||
'format' => EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(),
|
||||
'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(),
|
||||
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(),
|
||||
'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(),
|
||||
'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(),
|
||||
'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(),
|
||||
'logo_url' => EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(),
|
||||
],
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
return [
|
||||
|
||||
'rabbitmq' => [
|
||||
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false),
|
||||
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(),
|
||||
'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(),
|
||||
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(false),
|
||||
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'),
|
||||
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(),
|
||||
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv(),
|
||||
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
|
||||
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),
|
||||
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'),
|
||||
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv(),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ return [
|
||||
'rabbitmq' => [
|
||||
'enabled' => true,
|
||||
'host' => 'shlink_rabbitmq',
|
||||
'port' => '5672',
|
||||
'port' => 5672,
|
||||
'user' => 'rabbit',
|
||||
'password' => 'rabbit',
|
||||
],
|
||||
|
||||
@@ -4,9 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
|
||||
return [
|
||||
|
||||
'not_found_redirects' => [
|
||||
@@ -16,10 +13,8 @@ return [
|
||||
],
|
||||
|
||||
'redirects' => [
|
||||
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value),
|
||||
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(
|
||||
DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
),
|
||||
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(),
|
||||
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
14
config/autoload/robots.global.php
Normal file
14
config/autoload/robots.global.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
return [
|
||||
|
||||
'robots' => [
|
||||
'allow-all-short-urls' => (bool) Config\EnvVars::ROBOTS_ALLOW_ALL_SHORT_URLS->loadFromEnv(),
|
||||
'user-agents' => splitByComma(Config\EnvVars::ROBOTS_USER_AGENTS->loadFromEnv()),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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' => [
|
||||
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console
|
||||
|
||||
@@ -21,7 +21,7 @@ return (static function (): array {
|
||||
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
|
||||
|
||||
// TODO This should be based on config, not the env var
|
||||
$shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false) ? '[/]' : '';
|
||||
$shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv() ? '[/]' : '';
|
||||
|
||||
return [
|
||||
|
||||
|
||||
@@ -4,40 +4,35 @@ declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
return (static function (): array {
|
||||
/** @var string|null $disableTrackingFrom */
|
||||
$disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv();
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
return [
|
||||
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),
|
||||
'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(),
|
||||
|
||||
// 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),
|
||||
// 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(),
|
||||
|
||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
||||
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
|
||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
||||
'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),
|
||||
// If true, visits will not be tracked at all
|
||||
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(),
|
||||
|
||||
// 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),
|
||||
// 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(),
|
||||
|
||||
// If true, the referrer will not be tracked
|
||||
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
|
||||
// If true, the referrer will not be tracked
|
||||
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(),
|
||||
|
||||
// If true, the user agent will not be tracked
|
||||
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
|
||||
// If true, the user agent will not be tracked
|
||||
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(),
|
||||
|
||||
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
|
||||
'disable_tracking_from' => $disableTrackingFrom === null
|
||||
? []
|
||||
: array_map(trim(...), explode(',', $disableTrackingFrom)),
|
||||
],
|
||||
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
|
||||
'disable_tracking_from' => splitByComma(EnvVars::DISABLE_TRACKING_FROM->loadFromEnv()),
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
];
|
||||
|
||||
@@ -5,29 +5,28 @@ declare(strict_types=1);
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
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(),
|
||||
MIN_SHORT_CODES_LENGTH,
|
||||
);
|
||||
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
|
||||
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv();
|
||||
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT;
|
||||
|
||||
return [
|
||||
|
||||
'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()) ? 'https' : 'http',
|
||||
'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(),
|
||||
],
|
||||
'default_short_codes_length' => $shortCodesLength,
|
||||
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(true),
|
||||
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
|
||||
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
|
||||
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),
|
||||
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(),
|
||||
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(),
|
||||
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(),
|
||||
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(),
|
||||
'mode' => $mode,
|
||||
],
|
||||
|
||||
|
||||
@@ -8,18 +8,13 @@ use Laminas\ConfigAggregator;
|
||||
use Laminas\Diactoros;
|
||||
use Mezzio;
|
||||
use Mezzio\ProblemDetails;
|
||||
use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
|
||||
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
$isTestEnv = env('APP_ENV') === 'test';
|
||||
|
||||
return (new ConfigAggregator\ConfigAggregator(
|
||||
providers: [
|
||||
! $isTestEnv
|
||||
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
|
||||
: new ConfigAggregator\ArrayProvider([]),
|
||||
Mezzio\ConfigProvider::class,
|
||||
Mezzio\Router\ConfigProvider::class,
|
||||
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
|
||||
|
||||
@@ -12,7 +12,6 @@ const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
|
||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
|
||||
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
|
||||
const DEFAULT_QR_CODE_SIZE = 300;
|
||||
const DEFAULT_QR_CODE_MARGIN = 0;
|
||||
|
||||
@@ -6,16 +6,21 @@ use Laminas\ServiceManager\ServiceManager;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use function Shlinkio\Shlink\Config\loadEnvVarsFromConfig;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
// Set a default memory limit, but allow custom values
|
||||
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv('512M'));
|
||||
// This is one of the first files loaded. Configure the timezone here
|
||||
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
|
||||
// Promote env vars from installer config
|
||||
loadEnvVarsFromConfig('config/params/generated_config.php', enumValues(EnvVars::class));
|
||||
|
||||
// This is one of the first files loaded. Configure the timezone and memory limit here
|
||||
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv());
|
||||
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv());
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_db_mysql:
|
||||
environment:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_php:
|
||||
user: 1000:1000
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_nginx:
|
||||
container_name: shlink_nginx
|
||||
@@ -79,7 +77,7 @@ services:
|
||||
|
||||
shlink_db_postgres:
|
||||
container_name: shlink_db_postgres
|
||||
image: postgres:12.2-alpine
|
||||
image: postgres:16.3-alpine
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
@@ -105,7 +103,7 @@ services:
|
||||
|
||||
shlink_db_ms:
|
||||
container_name: shlink_db_ms
|
||||
image: mcr.microsoft.com/mssql/server:2019-latest
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
ports:
|
||||
- "1433:1433"
|
||||
environment:
|
||||
@@ -147,7 +145,7 @@ services:
|
||||
SERVER_NAME: ":80"
|
||||
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
|
||||
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
|
||||
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
|
||||
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000 http://localhost:3002 http://127.0.0.1:3002 http://localhost:3005 http://127.0.0.1:3005"
|
||||
|
||||
shlink_rabbitmq:
|
||||
container_name: shlink_rabbitmq
|
||||
|
||||
@@ -8,14 +8,19 @@ mkdir -p data/cache data/locks data/log data/proxies
|
||||
|
||||
flags="--no-interaction --clear-db-cache"
|
||||
|
||||
# Read env vars through Shlink command, so that it applies the `_FILE` env var fallback logic
|
||||
geolite_license_key=$(bin/cli env-var:read GEOLITE_LICENSE_KEY)
|
||||
skip_initial_geolite_download=$(bin/cli env-var:read SKIP_INITIAL_GEOLITE_DOWNLOAD)
|
||||
initial_api_key=$(bin/cli env-var:read INITIAL_API_KEY)
|
||||
|
||||
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set
|
||||
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" = "true" ]; then
|
||||
if [ -z "${geolite_license_key}" ] || [ "${skip_initial_geolite_download}" = "true" ]; then
|
||||
flags="${flags} --skip-download-geolite"
|
||||
fi
|
||||
|
||||
# If INITIAL_API_KEY was provided, create an initial API key
|
||||
if [ -n "${INITIAL_API_KEY}" ]; then
|
||||
flags="${flags} --initial-api-key=${INITIAL_API_KEY}"
|
||||
if [ -n "${initial_api_key}" ]; then
|
||||
flags="${flags} --initial-api-key=${initial_api_key}"
|
||||
fi
|
||||
|
||||
php vendor/bin/shlink-installer init ${flags}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["device", "language", "query-param"],
|
||||
"description": "The type of the condition, which will condition the logic used to match it"
|
||||
"enum": ["device", "language", "query-param", "ip-address"],
|
||||
"description": "The type of the condition, which will determine the logic used to match it"
|
||||
},
|
||||
"matchKey": {
|
||||
"type": ["string", "null"]
|
||||
|
||||
@@ -77,12 +77,12 @@
|
||||
"priority": 3,
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"type": "query-param",
|
||||
"matchKey": "foo",
|
||||
"matchValue": "bar"
|
||||
},
|
||||
{
|
||||
"type": "query",
|
||||
"type": "query-param",
|
||||
"matchKey": "hello",
|
||||
"matchValue": "world"
|
||||
}
|
||||
@@ -209,12 +209,12 @@
|
||||
"longUrl": "https://example.com/query-foo-bar-hello-world",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"type": "query-param",
|
||||
"matchKey": "foo",
|
||||
"matchValue": "bar"
|
||||
},
|
||||
{
|
||||
"type": "query",
|
||||
"type": "query-param",
|
||||
"matchKey": "hello",
|
||||
"matchValue": "world"
|
||||
}
|
||||
@@ -280,12 +280,12 @@
|
||||
"priority": 3,
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"type": "query-param",
|
||||
"matchKey": "foo",
|
||||
"matchValue": "bar"
|
||||
},
|
||||
{
|
||||
"type": "query",
|
||||
"type": "query-param",
|
||||
"matchKey": "hello",
|
||||
"matchValue": "world"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'cli' => [
|
||||
'commands' => [
|
||||
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
|
||||
Command\ShortUrl\EditShortUrlCommand::NAME => Command\ShortUrl\EditShortUrlCommand::class,
|
||||
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
|
||||
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
|
||||
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
|
||||
@@ -44,6 +45,8 @@ return [
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class,
|
||||
|
||||
Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class,
|
||||
|
||||
Command\Config\ReadEnvVarCommand::NAME => Command\Config\ReadEnvVarCommand::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\EditShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -74,6 +75,8 @@ return [
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Integration\MatomoSendVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Config\ReadEnvVarCommand::class => InvokableFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -92,6 +95,7 @@ return [
|
||||
ShortUrlStringifier::class,
|
||||
UrlShortenerOptions::class,
|
||||
],
|
||||
Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class, ShortUrlStringifier::class],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [
|
||||
ShortUrl\ShortUrlListService::class,
|
||||
|
||||
68
module/CLI/src/Command/Config/ReadEnvVarCommand.php
Normal file
68
module/CLI/src/Command/Config/ReadEnvVarCommand.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Config;
|
||||
|
||||
use Closure;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Shlinkio\Shlink\Config\formatEnvVarValue;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
use function sprintf;
|
||||
|
||||
class ReadEnvVarCommand extends Command
|
||||
{
|
||||
public const NAME = 'env-var:read';
|
||||
|
||||
/** @var Closure(string $envVar): mixed */
|
||||
private readonly Closure $loadEnvVar;
|
||||
|
||||
public function __construct(?Closure $loadEnvVar = null)
|
||||
{
|
||||
$this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv();
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setHidden()
|
||||
->setDescription('Display current value for an env var')
|
||||
->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$envVar = $input->getArgument('envVar');
|
||||
$validEnvVars = enumValues(EnvVars::class);
|
||||
|
||||
if ($envVar === null) {
|
||||
$envVar = $io->choice('Select the env var to read', $validEnvVars);
|
||||
}
|
||||
|
||||
if (! contains($envVar, $validEnvVars)) {
|
||||
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
|
||||
}
|
||||
|
||||
$input->setArgument('envVar', $envVar);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$envVar = $input->getArgument('envVar');
|
||||
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
private ProcessRunnerInterface $processRunner,
|
||||
private readonly ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder,
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
|
||||
@@ -33,6 +33,9 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
||||
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$domain = $input->getArgument('domain');
|
||||
|
||||
@@ -4,24 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
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 array_map;
|
||||
use function array_unique;
|
||||
use function explode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function sprintf;
|
||||
|
||||
class CreateShortUrlCommand extends Command
|
||||
@@ -29,6 +23,7 @@ class CreateShortUrlCommand extends Command
|
||||
public const NAME = 'short-url:create';
|
||||
|
||||
private ?SymfonyStyle $io;
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
|
||||
public function __construct(
|
||||
private readonly UrlShortenerInterface $urlShortener,
|
||||
@@ -36,6 +31,7 @@ class CreateShortUrlCommand extends Command
|
||||
private readonly UrlShortenerOptions $options,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->shortUrlDataInput = new ShortUrlDataInput($this);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -43,26 +39,11 @@ class CreateShortUrlCommand extends Command
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Generates a short URL for provided long URL and returns it')
|
||||
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the new short URL',
|
||||
)
|
||||
->addOption(
|
||||
'valid-since',
|
||||
's',
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
'valid-until',
|
||||
'u',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.',
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'custom-slug',
|
||||
@@ -70,30 +51,6 @@ class CreateShortUrlCommand extends Command
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'If provided, this slug will be used instead of generating a short code',
|
||||
)
|
||||
->addOption(
|
||||
'path-prefix',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Prefix to prepend before the generated short code or provided custom slug',
|
||||
)
|
||||
->addOption(
|
||||
'max-visits',
|
||||
'm',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'This will limit the number of visits for this short URL.',
|
||||
)
|
||||
->addOption(
|
||||
'find-if-exists',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'This will force existing matching URL to be returned if found, instead of creating a new one.',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'short-code-length',
|
||||
'l',
|
||||
@@ -101,16 +58,16 @@ class CreateShortUrlCommand extends Command
|
||||
'The length for generated short code (it will be ignored if --custom-slug was provided).',
|
||||
)
|
||||
->addOption(
|
||||
'crawlable',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
|
||||
'path-prefix',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Prefix to prepend before the generated short code or provided custom slug',
|
||||
)
|
||||
->addOption(
|
||||
'no-forward-query',
|
||||
'w',
|
||||
'find-if-exists',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
|
||||
'This will force existing matching URL to be returned if found, instead of creating a new one.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,32 +93,17 @@ class CreateShortUrlCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = $this->getIO($input, $output);
|
||||
$longUrl = $input->getArgument('longUrl');
|
||||
if (empty($longUrl)) {
|
||||
$io->error('A URL was not provided!');
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
|
||||
$explodeWithComma = static fn (string $tag) => explode(',', $tag);
|
||||
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
|
||||
|
||||
try {
|
||||
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => $longUrl,
|
||||
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
|
||||
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
|
||||
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
|
||||
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
|
||||
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
|
||||
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlInputFilter::TAGS => $tags,
|
||||
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
], $this->options));
|
||||
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
|
||||
$input,
|
||||
$this->options,
|
||||
customSlugField: 'custom-slug',
|
||||
shortCodeLengthField: 'short-code-length',
|
||||
pathPrefixField: 'path-prefix',
|
||||
findIfExistsField: 'find-if-exists',
|
||||
domainField: 'domain',
|
||||
));
|
||||
|
||||
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
|
||||
'Short URL properly created, but the real-time updates cannot be notified when generating the '
|
||||
@@ -169,7 +111,7 @@ class CreateShortUrlCommand extends Command
|
||||
));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
sprintf('Processed long URL: <info>%s</info>', $result->shortUrl->getLongUrl()),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
|
||||
]);
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
@@ -181,6 +123,6 @@ class CreateShortUrlCommand extends Command
|
||||
|
||||
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
|
||||
{
|
||||
return $this->io ?? ($this->io = new SymfonyStyle($input, $output));
|
||||
return $this->io ??= new SymfonyStyle($input, $output);
|
||||
}
|
||||
}
|
||||
|
||||
71
module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
Normal file
71
module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class EditShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:edit';
|
||||
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
public function __construct(
|
||||
private readonly ShortUrlServiceInterface $shortUrlService,
|
||||
private readonly ShortUrlStringifierInterface $stringifier,
|
||||
) {
|
||||
parent::__construct();
|
||||
|
||||
$this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code to edit',
|
||||
domainDesc: 'The domain to which the short URL is attached.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Edit an existing short URL');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
|
||||
try {
|
||||
$shortUrl = $this->shortUrlService->updateShortUrl(
|
||||
$identifier,
|
||||
$this->shortUrlDataInput->toShortUrlEdition($input),
|
||||
);
|
||||
|
||||
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$io->error(sprintf('Short URL not found for "%s"', $identifier->__toString()));
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()?->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
|
||||
@@ -9,14 +9,14 @@ use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -32,8 +32,6 @@ use function sprintf;
|
||||
|
||||
class ListShortUrlsCommand extends Command
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
@@ -41,7 +39,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
public function __construct(
|
||||
private readonly ShortUrlListServiceInterface $shortUrlService,
|
||||
private readonly DataTransformerInterface $transformer,
|
||||
private readonly ShortUrlDataTransformerInterface $transformer,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->startDateOption = new StartDateOption($this, 'short URLs');
|
||||
@@ -179,6 +177,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
/**
|
||||
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
|
||||
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||
*/
|
||||
private function renderPage(
|
||||
OutputInterface $output,
|
||||
@@ -196,7 +195,7 @@ class ListShortUrlsCommand extends Command
|
||||
ShlinkTable::default($output)->render(
|
||||
array_keys($columnsMap),
|
||||
$rows,
|
||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
$all ? null : PagerfantaUtils::formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
);
|
||||
|
||||
return $shortUrls;
|
||||
|
||||
@@ -33,6 +33,9 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$tag = $input->getArgument('tag');
|
||||
|
||||
@@ -46,6 +46,9 @@ abstract class AbstractVisitsListCommand extends Command
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Paginator<Visit> $paginator
|
||||
*/
|
||||
private function resolveRowsAndHeaders(Paginator $paginator): array
|
||||
{
|
||||
$extraKeys = [];
|
||||
@@ -74,6 +77,9 @@ abstract class AbstractVisitsListCommand extends Command
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,9 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
->setDescription('Returns the list of non-orphan visits.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
|
||||
|
||||
@@ -30,6 +30,9 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$rawType = $input->getOption('type');
|
||||
|
||||
136
module/CLI/src/Input/ShortUrlDataInput.php
Normal file
136
module/CLI/src/Input/ShortUrlDataInput.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
readonly final class ShortUrlDataInput
|
||||
{
|
||||
public function __construct(Command $command, private bool $longUrlAsOption = false)
|
||||
{
|
||||
if ($longUrlAsOption) {
|
||||
$command->addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
|
||||
} else {
|
||||
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
|
||||
}
|
||||
|
||||
$command
|
||||
->addOption(
|
||||
ShortUrlDataOption::TAGS->value,
|
||||
ShortUrlDataOption::TAGS->shortcut(),
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the short URL',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::VALID_SINCE->value,
|
||||
ShortUrlDataOption::VALID_SINCE->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::VALID_UNTIL->value,
|
||||
ShortUrlDataOption::VALID_UNTIL->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::MAX_VISITS->value,
|
||||
ShortUrlDataOption::MAX_VISITS->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'This will limit the number of visits for this short URL.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::TITLE->value,
|
||||
ShortUrlDataOption::TITLE->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A descriptive title for the short URL.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::CRAWLABLE->value,
|
||||
ShortUrlDataOption::CRAWLABLE->shortcut(),
|
||||
InputOption::VALUE_NONE,
|
||||
'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::NO_FORWARD_QUERY->value,
|
||||
ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
|
||||
InputOption::VALUE_NONE,
|
||||
'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
|
||||
);
|
||||
}
|
||||
|
||||
public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
|
||||
{
|
||||
return ShortUrlEdition::fromRawData($this->getCommonData($input));
|
||||
}
|
||||
|
||||
public function toShortUrlCreation(
|
||||
InputInterface $input,
|
||||
UrlShortenerOptions $options,
|
||||
string $customSlugField,
|
||||
string $shortCodeLengthField,
|
||||
string $pathPrefixField,
|
||||
string $findIfExistsField,
|
||||
string $domainField,
|
||||
): ShortUrlCreation {
|
||||
$shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
|
||||
return ShortUrlCreation::fromRawData([
|
||||
...$this->getCommonData($input),
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
|
||||
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
|
||||
ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
|
||||
], $options);
|
||||
}
|
||||
|
||||
private function getCommonData(InputInterface $input): array
|
||||
{
|
||||
$longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
|
||||
$data = [ShortUrlInputFilter::LONG_URL => $longUrl];
|
||||
|
||||
// Avoid setting arguments that were not explicitly provided.
|
||||
// This is important when editing short URLs and should not make a difference when creating.
|
||||
if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
|
||||
}
|
||||
if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
|
||||
}
|
||||
if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
}
|
||||
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
|
||||
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
|
||||
$data[ShortUrlInputFilter::TAGS] = $tags;
|
||||
}
|
||||
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
|
||||
}
|
||||
if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
|
||||
}
|
||||
if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
41
module/CLI/src/Input/ShortUrlDataOption.php
Normal file
41
module/CLI/src/Input/ShortUrlDataOption.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
enum ShortUrlDataOption: string
|
||||
{
|
||||
case TAGS = 'tags';
|
||||
case VALID_SINCE = 'valid-since';
|
||||
case VALID_UNTIL = 'valid-until';
|
||||
case MAX_VISITS = 'max-visits';
|
||||
case TITLE = 'title';
|
||||
case CRAWLABLE = 'crawlable';
|
||||
case NO_FORWARD_QUERY = 'no-forward-query';
|
||||
|
||||
public function shortcut(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TAGS => 't',
|
||||
self::VALID_SINCE => 's',
|
||||
self::VALID_UNTIL => 'u',
|
||||
self::MAX_VISITS => 'm',
|
||||
self::TITLE => null,
|
||||
self::CRAWLABLE => 'r',
|
||||
self::NO_FORWARD_QUERY => 'w',
|
||||
};
|
||||
}
|
||||
|
||||
public function wasProvided(InputInterface $input): bool
|
||||
{
|
||||
$option = sprintf('--%s', $this->value);
|
||||
$shortcut = $this->shortcut();
|
||||
|
||||
return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
$this->askMandatory('Query param name?', $io),
|
||||
$this->askOptional('Query param value?', $io),
|
||||
),
|
||||
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
|
||||
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
|
||||
),
|
||||
};
|
||||
|
||||
$continue = $io->confirm('Do you want to add another condition?');
|
||||
|
||||
54
module/CLI/test/Command/Config/ReadEnvVarCommandTest.php
Normal file
54
module/CLI/test/Command/Config/ReadEnvVarCommandTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Config;
|
||||
|
||||
use Monolog\Test\TestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\Config\ReadEnvVarCommand;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ReadEnvVarCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private string $envVarValue = 'the_env_var_value';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->commandTester = CliTestUtils::testerForCommand(new ReadEnvVarCommand(fn () => $this->envVarValue));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsThrownIfProvidedEnvVarIsInvalid(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('foo is not a valid Shlink environment variable');
|
||||
|
||||
$this->commandTester->execute(['envVar' => 'foo']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function valueIsPrintedIfProvidedEnvVarIsValid(): void
|
||||
{
|
||||
$this->commandTester->execute(['envVar' => EnvVars::BASE_PATH->value]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringNotContainsString('Select the env var to read', $output);
|
||||
self::assertStringContainsString($this->envVarValue, $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function envVarNameIsRequestedIfArgumentIsMissing(): void
|
||||
{
|
||||
$this->commandTester->setInputs([EnvVars::BASE_PATH->value]);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Select the env var to read', $output);
|
||||
self::assertStringContainsString($this->envVarValue, $output);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Driver;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Platforms\SQLitePlatform;
|
||||
use Doctrine\DBAL\Schema\AbstractSchemaManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
@@ -31,6 +32,7 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
private MockObject & ProcessRunnerInterface $processHelper;
|
||||
private MockObject & Connection $regularConn;
|
||||
private MockObject & ClassMetadataFactory $metadataFactory;
|
||||
/** @var MockObject&AbstractSchemaManager<SQLitePlatform> */
|
||||
private MockObject & AbstractSchemaManager $schemaManager;
|
||||
private MockObject & Driver $driver;
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ class MatomoSendVisitsCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([true])]
|
||||
#[TestWith([false])]
|
||||
#[TestWith([true], 'interactive')]
|
||||
#[TestWith([false], 'not interactive')]
|
||||
public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void
|
||||
{
|
||||
$this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());
|
||||
|
||||
74
module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php
Normal file
74
module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\EditShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class EditShortUrlCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & ShortUrlServiceInterface $shortUrlService;
|
||||
private MockObject & ShortUrlStringifierInterface $stringifier;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class);
|
||||
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
|
||||
|
||||
$command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier);
|
||||
$this->commandTester = CliTestUtils::testerForCommand($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successMessageIsPrintedIfNoErrorOccurs(): void
|
||||
{
|
||||
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(
|
||||
ShortUrl::createFake(),
|
||||
);
|
||||
$this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo');
|
||||
|
||||
$this->commandTester->execute(['shortCode' => 'foobar']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString('Short URL "https://s.test/foo" properly edited', $output);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([OutputInterface::VERBOSITY_NORMAL])]
|
||||
#[TestWith([OutputInterface::VERBOSITY_VERBOSE])]
|
||||
#[TestWith([OutputInterface::VERBOSITY_VERY_VERBOSE])]
|
||||
#[TestWith([OutputInterface::VERBOSITY_DEBUG])]
|
||||
public function errorIsPrintedInCaseOfFailure(int $verbosity): void
|
||||
{
|
||||
$e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('foo'));
|
||||
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e);
|
||||
$this->stringifier->expects($this->never())->method('stringify');
|
||||
|
||||
$this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString('Short URL not found for "foo"', $output);
|
||||
if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
|
||||
self::assertStringContainsString('Exception trace:', $output);
|
||||
} else {
|
||||
self::assertStringNotContainsString('Exception trace:', $output);
|
||||
}
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,7 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
'Language to match?' => 'en-US',
|
||||
'Query param name?' => 'foo',
|
||||
'Query param value?' => 'bar',
|
||||
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
|
||||
default => '',
|
||||
},
|
||||
);
|
||||
@@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
true,
|
||||
];
|
||||
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -25,6 +25,7 @@ class CliTestUtils
|
||||
$command = $generator->testDouble(
|
||||
Command::class,
|
||||
mockObject: true,
|
||||
markAsMockObject: true,
|
||||
callOriginalConstructor: false,
|
||||
callOriginalClone: false,
|
||||
cloneArguments: false,
|
||||
|
||||
@@ -32,6 +32,7 @@ return [
|
||||
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
|
||||
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
|
||||
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
|
||||
Options\RobotsOptions::class => [ValinorConfigFactory::class, 'config.robots'],
|
||||
|
||||
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
|
||||
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
|
||||
@@ -189,7 +190,7 @@ return [
|
||||
'Logger_Shlink',
|
||||
Options\QrCodeOptions::class,
|
||||
],
|
||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Options\RobotsOptions::class],
|
||||
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
|
||||
'em',
|
||||
|
||||
@@ -9,11 +9,13 @@ use Cake\Chronos\Chronos;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
|
||||
use GuzzleHttp\Psr7\Query;
|
||||
use Hidehalo\Nanoid\Client as NanoidClient;
|
||||
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||
use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
@@ -37,15 +39,15 @@ use function ucfirst;
|
||||
|
||||
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
|
||||
{
|
||||
static $shortIdFactory;
|
||||
if ($shortIdFactory === null) {
|
||||
$shortIdFactory = new ShortIdFactory();
|
||||
static $nanoIdClient;
|
||||
if ($nanoIdClient === null) {
|
||||
$nanoIdClient = new NanoidClient();
|
||||
}
|
||||
|
||||
$alphabet = $mode === ShortUrlMode::STRICT
|
||||
? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
: '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
return $shortIdFactory->generate($length, $alphabet)->serialize();
|
||||
return $nanoIdClient->formattedId($alphabet, $length);
|
||||
}
|
||||
|
||||
function parseDateFromQuery(array $query, string $dateName): ?Chronos
|
||||
@@ -107,7 +109,6 @@ function normalizeLocale(string $locale): string
|
||||
* minimum quality
|
||||
*
|
||||
* @param non-empty-string $acceptLanguage
|
||||
* @param float<0, 1> $minQuality
|
||||
* @return iterable<string>;
|
||||
*/
|
||||
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
|
||||
@@ -140,21 +141,31 @@ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0):
|
||||
*/
|
||||
function splitLocale(string $locale): array
|
||||
{
|
||||
return array_pad(explode('-', $locale), length: 2, value: null);
|
||||
[$lang, $countryCode] = array_pad(explode('-', $locale), length: 2, value: null);
|
||||
return [$lang, $countryCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (bool) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
@@ -260,3 +271,21 @@ function enumToString(string $enum): string
|
||||
{
|
||||
return sprintf('["%s"]', implode('", "', enumValues($enum)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split provided string by comma and return a list of the results.
|
||||
* An empty array is returned if provided value is empty
|
||||
*/
|
||||
function splitByComma(?string $value): array
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(trim(...), explode(',', $value));
|
||||
}
|
||||
|
||||
function ipAddressFromRequest(ServerRequestInterface $request): ?string
|
||||
{
|
||||
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Options\RobotsOptions;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
||||
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
||||
readonly class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
||||
{
|
||||
public function __construct(private readonly CrawlingHelperInterface $crawlingHelper)
|
||||
public function __construct(private CrawlingHelperInterface $crawlingHelper, private RobotsOptions $robotsOptions)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -33,10 +34,20 @@ class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
||||
# For more information about the robots.txt standard, see:
|
||||
# https://www.robotstxt.org/orig.html
|
||||
|
||||
User-agent: *
|
||||
|
||||
ROBOTS;
|
||||
|
||||
$userAgents = $this->robotsOptions->hasUserAgents() ? $this->robotsOptions->userAgents : ['*'];
|
||||
foreach ($userAgents as $userAgent) {
|
||||
yield sprintf('User-agent: %s%s', $userAgent, PHP_EOL);
|
||||
}
|
||||
|
||||
if ($this->robotsOptions->allowAllShortUrls) {
|
||||
// Disallow rest URLs, but allow all short codes
|
||||
yield 'Disallow: /rest/';
|
||||
return;
|
||||
}
|
||||
|
||||
$shortCodes = $this->crawlingHelper->listCrawlableShortCodes();
|
||||
foreach ($shortCodes as $shortCode) {
|
||||
yield sprintf('Allow: /%s%s', $shortCode, PHP_EOL);
|
||||
|
||||
@@ -4,12 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use function date_default_timezone_get;
|
||||
use function file_get_contents;
|
||||
use function is_file;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
use function Shlinkio\Shlink\Config\parseEnvVar;
|
||||
use function sprintf;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
enum EnvVars: string
|
||||
{
|
||||
case DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD';
|
||||
@@ -69,13 +84,17 @@ enum EnvVars: string
|
||||
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';
|
||||
case ROBOTS_ALLOW_ALL_SHORT_URLS = 'ROBOTS_ALLOW_ALL_SHORT_URLS';
|
||||
case ROBOTS_USER_AGENTS = 'ROBOTS_USER_AGENTS';
|
||||
case TIMEZONE = 'TIMEZONE';
|
||||
case MEMORY_LIMIT = 'MEMORY_LIMIT';
|
||||
case INITIAL_API_KEY = 'INITIAL_API_KEY';
|
||||
case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD';
|
||||
|
||||
public function loadFromEnv(mixed $default = null): mixed
|
||||
public function loadFromEnv(): mixed
|
||||
{
|
||||
return env($this->value) ?? $this->loadFromFileEnv() ?? $default;
|
||||
return env($this->value) ?? $this->loadFromFileEnv() ?? $this->defaultValue();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +114,62 @@ enum EnvVars: string
|
||||
return $content ? parseEnvVar($content) : null;
|
||||
}
|
||||
|
||||
private function defaultValue(): string|int|bool|null
|
||||
{
|
||||
return match ($this) {
|
||||
self::MEMORY_LIMIT => '512M',
|
||||
self::TIMEZONE => date_default_timezone_get(),
|
||||
|
||||
self::DEFAULT_SHORT_CODES_LENGTH => DEFAULT_SHORT_CODES_LENGTH,
|
||||
self::SHORT_URL_MODE => ShortUrlMode::STRICT->value,
|
||||
self::IS_HTTPS_ENABLED, self::AUTO_RESOLVE_TITLES => true,
|
||||
self::REDIRECT_APPEND_EXTRA_PATH,
|
||||
self::MULTI_SEGMENT_SLUGS_ENABLED,
|
||||
self::SHORT_URL_TRAILING_SLASH => false,
|
||||
self::DEFAULT_DOMAIN, self::BASE_PATH => '',
|
||||
self::CACHE_NAMESPACE => 'Shlink',
|
||||
|
||||
self::REDIS_PUB_SUB_ENABLED,
|
||||
self::MATOMO_ENABLED,
|
||||
self::ROBOTS_ALLOW_ALL_SHORT_URLS => false,
|
||||
|
||||
self::DB_NAME => 'shlink',
|
||||
self::DB_HOST => self::DB_UNIX_SOCKET->loadFromEnv(),
|
||||
self::DB_DRIVER => 'sqlite',
|
||||
self::DB_PORT => match (self::DB_DRIVER->loadFromEnv()) {
|
||||
'postgres' => '5432',
|
||||
'mssql' => '1433',
|
||||
default => '3306',
|
||||
},
|
||||
|
||||
self::MERCURE_INTERNAL_HUB_URL => self::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
|
||||
|
||||
self::DEFAULT_QR_CODE_SIZE, => DEFAULT_QR_CODE_SIZE,
|
||||
self::DEFAULT_QR_CODE_MARGIN, => DEFAULT_QR_CODE_MARGIN,
|
||||
self::DEFAULT_QR_CODE_FORMAT, => DEFAULT_QR_CODE_FORMAT,
|
||||
self::DEFAULT_QR_CODE_ERROR_CORRECTION, => DEFAULT_QR_CODE_ERROR_CORRECTION,
|
||||
self::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, => DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
|
||||
self::QR_CODE_FOR_DISABLED_SHORT_URLS, => DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
|
||||
self::DEFAULT_QR_CODE_COLOR, => DEFAULT_QR_CODE_COLOR,
|
||||
self::DEFAULT_QR_CODE_BG_COLOR, => DEFAULT_QR_CODE_BG_COLOR,
|
||||
|
||||
self::RABBITMQ_ENABLED, self::RABBITMQ_USE_SSL => false,
|
||||
self::RABBITMQ_PORT => 5672,
|
||||
self::RABBITMQ_VHOST => '/',
|
||||
|
||||
self::REDIRECT_STATUS_CODE => DEFAULT_REDIRECT_STATUS_CODE->value,
|
||||
self::REDIRECT_CACHE_LIFETIME => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
|
||||
self::ANONYMIZE_REMOTE_ADDR, self::TRACK_ORPHAN_VISITS => true,
|
||||
self::DISABLE_TRACKING,
|
||||
self::DISABLE_IP_TRACKING,
|
||||
self::DISABLE_REFERRER_TRACKING,
|
||||
self::DISABLE_UA_TRACKING => false,
|
||||
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function existsInEnv(): bool
|
||||
{
|
||||
return $this->loadFromEnv() !== null;
|
||||
|
||||
@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @extends EntitySpecificationRepository<Domain> */
|
||||
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterfa
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @extends ObjectRepository<Domain> */
|
||||
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ use Laminas\InputFilter\InputFilter;
|
||||
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
|
||||
use Shlinkio\Shlink\Common\Validation\InputFactory;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class DomainRedirectsInputFilter extends InputFilter
|
||||
{
|
||||
public const DOMAIN = 'domain';
|
||||
|
||||
@@ -4,21 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
|
||||
final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface
|
||||
{
|
||||
public function __construct(private DataTransformerInterface $shortUrlTransformer)
|
||||
public function __construct(private ShortUrlDataTransformerInterface $shortUrlTransformer)
|
||||
{
|
||||
}
|
||||
|
||||
public function newVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($visit->shortUrl),
|
||||
'shortUrl' => $this->transformShortUrl($visit->shortUrl),
|
||||
'visit' => $visit->jsonSerialize(),
|
||||
]);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
|
||||
$topic = Topic::newShortUrlVisit($shortUrl?->getShortCode());
|
||||
|
||||
return Update::forTopicAndPayload($topic, [
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
||||
'shortUrl' => $this->transformShortUrl($shortUrl),
|
||||
'visit' => $visit->jsonSerialize(),
|
||||
]);
|
||||
}
|
||||
@@ -47,4 +47,9 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
private function transformShortUrl(?ShortUrl $shortUrl): array
|
||||
{
|
||||
return $shortUrl === null ? [] : $this->shortUrlTransformer->transform($shortUrl);
|
||||
}
|
||||
}
|
||||
|
||||
15
module/Core/src/Exception/InvalidIpFormatException.php
Normal file
15
module/Core/src/Exception/InvalidIpFormatException.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class InvalidIpFormatException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
public static function fromInvalidIp(string $ipAddress): self
|
||||
{
|
||||
return new self(sprintf('Provided IP %s does not have the right format. Expected X.X.X.X', $ipAddress));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
||||
|
||||
private array $invalidElements;
|
||||
|
||||
/**
|
||||
* @param InputFilterInterface<mixed> $inputFilter
|
||||
*/
|
||||
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
|
||||
{
|
||||
return static::fromArray($inputFilter->getMessages(), $prev);
|
||||
|
||||
22
module/Core/src/Options/RobotsOptions.php
Normal file
22
module/Core/src/Options/RobotsOptions.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use function count;
|
||||
|
||||
final readonly class RobotsOptions
|
||||
{
|
||||
public function __construct(
|
||||
public bool $allowAllShortUrls = false,
|
||||
/** @var string[] */
|
||||
public array $userAgents = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function hasUserAgents(): bool
|
||||
{
|
||||
return count($this->userAgents) > 0;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @implements AdapterInterface<T>
|
||||
*/
|
||||
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
private ?int $count = null;
|
||||
|
||||
@@ -8,9 +8,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
use function Shlinkio\Shlink\Core\normalizeLocale;
|
||||
use function Shlinkio\Shlink\Core\splitLocale;
|
||||
use function sprintf;
|
||||
@@ -41,6 +43,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
return new self(RedirectConditionType::DEVICE, $device->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $ipAddressPattern - A static IP address (100.200.80.40), CIDR block (192.168.10.0/24) or wildcard
|
||||
* pattern (11.22.*.*)
|
||||
*/
|
||||
public static function forIpAddress(string $ipAddressPattern): self
|
||||
{
|
||||
return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern);
|
||||
}
|
||||
|
||||
public static function fromRawData(array $rawData): self
|
||||
{
|
||||
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
|
||||
@@ -59,6 +70,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
|
||||
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
|
||||
RedirectConditionType::DEVICE => $this->matchesDevice($request),
|
||||
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +112,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
return $device !== null && $device->value === strtolower($this->matchValue);
|
||||
}
|
||||
|
||||
private function matchesRemoteIpAddress(ServerRequestInterface $request): bool
|
||||
{
|
||||
$remoteAddress = ipAddressFromRequest($request);
|
||||
return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
@@ -119,6 +137,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
$this->matchKey,
|
||||
$this->matchValue,
|
||||
),
|
||||
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ enum RedirectConditionType: string
|
||||
case DEVICE = 'device';
|
||||
case LANGUAGE = 'language';
|
||||
case QUERY_PARAM = 'query-param';
|
||||
case IP_ADDRESS = 'ip-address';
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class RedirectRulesInputFilter extends InputFilter
|
||||
{
|
||||
public const REDIRECT_RULES = 'redirectRules';
|
||||
@@ -43,6 +45,9 @@ class RedirectRulesInputFilter extends InputFilter
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InputFilter<mixed>
|
||||
*/
|
||||
private static function createRedirectRuleInputFilter(): InputFilter
|
||||
{
|
||||
$redirectRuleInputFilter = new InputFilter();
|
||||
@@ -59,6 +64,9 @@ class RedirectRulesInputFilter extends InputFilter
|
||||
return $redirectRuleInputFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InputFilter<mixed>
|
||||
*/
|
||||
private static function createRedirectConditionInputFilter(): InputFilter
|
||||
{
|
||||
$redirectConditionInputFilter = new InputFilter();
|
||||
@@ -71,13 +79,14 @@ class RedirectRulesInputFilter extends InputFilter
|
||||
$redirectConditionInputFilter->add($type);
|
||||
|
||||
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
|
||||
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
|
||||
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
|
||||
return contains($value, enumValues(DeviceType::class));
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
$value->getValidatorChain()->attach(new Callback(
|
||||
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
|
||||
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
|
||||
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
|
||||
// RedirectConditionType::LANGUAGE->value => TODO,
|
||||
default => true,
|
||||
},
|
||||
));
|
||||
$redirectConditionInputFilter->add($value);
|
||||
|
||||
$redirectConditionInputFilter->add(
|
||||
|
||||
@@ -37,8 +37,8 @@ class ShortUrl extends AbstractEntity
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Tag> $tags
|
||||
* @param Collection<int, Visit> & Selectable $visits
|
||||
* @param Collection<int, ShortUrlVisitsCount> & Selectable $visitsCounts
|
||||
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
|
||||
* @param Collection<int, ShortUrlVisitsCount> & Selectable<int, ShortUrlVisitsCount> $visitsCounts
|
||||
*/
|
||||
private function __construct(
|
||||
private string $longUrl,
|
||||
@@ -213,7 +213,7 @@ class ShortUrl extends AbstractEntity
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Visit> & Selectable $visits
|
||||
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
|
||||
* @internal
|
||||
*/
|
||||
public function setVisits(Collection & Selectable $visits): self
|
||||
|
||||
@@ -28,11 +28,15 @@ readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderI
|
||||
?string $extraPath = null,
|
||||
): string {
|
||||
$uri = new Uri($this->redirectionResolver->resolveLongUrl($shortUrl, $request));
|
||||
$currentQuery = $request->getQueryParams();
|
||||
$shouldForwardQuery = $shortUrl->forwardQuery();
|
||||
$baseQueryString = $uri->getQuery();
|
||||
$basePath = $uri->getPath();
|
||||
|
||||
// Get current query by manually parsing query string, instead of using $request->getQueryParams().
|
||||
// That prevents some weird PHP logic in which some characters in param names are converted to ensure resulting
|
||||
// names are valid variable names.
|
||||
$currentQuery = Query::parse($request->getUri()->getQuery());
|
||||
|
||||
return $uri
|
||||
->withQuery($shouldForwardQuery ? $this->resolveQuery($baseQueryString, $currentQuery) : $baseQueryString)
|
||||
->withPath($this->resolvePath($basePath, $extraPath))
|
||||
|
||||
@@ -12,20 +12,24 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Throwable;
|
||||
|
||||
use function html_entity_decode;
|
||||
use function mb_convert_encoding;
|
||||
use function preg_match;
|
||||
use function str_contains;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
|
||||
|
||||
readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
|
||||
{
|
||||
public const MAX_REDIRECTS = 15;
|
||||
public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/121.0.0.0 Safari/537.36';
|
||||
|
||||
// Matches the value inside a html title tag
|
||||
private const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i';
|
||||
// Matches the charset inside a Content-Type header
|
||||
private const CHARSET_VALUE = '/charset=([^;]+)/i';
|
||||
|
||||
public function __construct(
|
||||
private ClientInterface $httpClient,
|
||||
private UrlShortenerOptions $options,
|
||||
@@ -53,7 +57,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
|
||||
return $data;
|
||||
}
|
||||
|
||||
$title = $this->tryToResolveTitle($response);
|
||||
$title = $this->tryToResolveTitle($response, $contentType);
|
||||
return $title !== null ? $data->withResolvedTitle($title) : $data;
|
||||
}
|
||||
|
||||
@@ -76,7 +80,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
|
||||
}
|
||||
}
|
||||
|
||||
private function tryToResolveTitle(ResponseInterface $response): ?string
|
||||
private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?string
|
||||
{
|
||||
$collectedBody = '';
|
||||
$body = $response->getBody();
|
||||
@@ -84,12 +88,19 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
|
||||
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
|
||||
$collectedBody .= $body->read(1024);
|
||||
}
|
||||
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
|
||||
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
|
||||
}
|
||||
|
||||
private function normalizeTitle(string $title): string
|
||||
{
|
||||
// Try to match the title from the <title /> tag
|
||||
preg_match(self::TITLE_TAG_VALUE, $collectedBody, $titleMatches);
|
||||
if (! isset($titleMatches[1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the page's charset from Content-Type header
|
||||
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
|
||||
|
||||
$title = isset($charsetMatches[1])
|
||||
? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1])
|
||||
: $titleMatches[1];
|
||||
return html_entity_decode(trim($title));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use function substr;
|
||||
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
|
||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class ShortUrlInputFilter extends InputFilter
|
||||
{
|
||||
// Fields for creation only
|
||||
|
||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class ShortUrlsParamsInputFilter extends InputFilter
|
||||
{
|
||||
public const PAGE = 'page';
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter;
|
||||
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @implements AdapterInterface<ShortUrlWithVisitsSummary> */
|
||||
readonly class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class CrawlableShortCodesQuery extends EntitySpecificationRepository implements CrawlableShortCodesQueryInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class ExpiredShortUrlsRepository extends EntitySpecificationRepository implements ExpiredShortUrlsRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function sprintf;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
use function count;
|
||||
use function strtolower;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
|
||||
{
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl
|
||||
|
||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
/** @extends ObjectRepository<ShortUrl> */
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl;
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -21,7 +20,7 @@ readonly class ShortUrlListService implements ShortUrlListServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrlWithVisitsSummary[]|Paginator
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
interface ShortUrlListServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return ShortUrlWithVisitsSummary[]|Paginator
|
||||
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
}
|
||||
|
||||
@@ -4,24 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
|
||||
/**
|
||||
* @fixme Do not implement DataTransformerInterface, but a separate interface
|
||||
*/
|
||||
readonly class ShortUrlDataTransformer implements DataTransformerInterface
|
||||
readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface
|
||||
{
|
||||
public function __construct(private ShortUrlStringifierInterface $stringifier)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrlWithVisitsSummary|ShortUrl $data
|
||||
*/
|
||||
public function transform($data): array // phpcs:ignore
|
||||
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array
|
||||
{
|
||||
$shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data;
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
|
||||
interface ShortUrlDataTransformerInterface
|
||||
{
|
||||
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array;
|
||||
}
|
||||
@@ -7,9 +7,11 @@ namespace Shlinkio\Shlink\Core\Tag\Entity;
|
||||
use Doctrine\Common\Collections;
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
class Tag extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
/** @var Collections\Collection<int, ShortUrl> */
|
||||
private Collections\Collection $shortUrls;
|
||||
|
||||
public function __construct(private string $name)
|
||||
|
||||
@@ -12,6 +12,10 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @implements AdapterInterface<T>
|
||||
*/
|
||||
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
|
||||
/** @extends AbstractTagsPaginatorAdapter<TagInfo> */
|
||||
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||
{
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
|
||||
/** @extends AbstractTagsPaginatorAdapter<Tag> */
|
||||
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||
{
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
|
||||
@@ -23,6 +23,7 @@ use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
/** @extends EntitySpecificationRepository<Tag> */
|
||||
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
|
||||
{
|
||||
public function deleteByName(array $names): int
|
||||
|
||||
@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\Core\Tag\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @extends ObjectRepository<Tag> */
|
||||
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function deleteByName(array $names): int;
|
||||
|
||||
@@ -11,7 +11,6 @@ use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
|
||||
@@ -20,14 +19,14 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class TagService implements TagServiceInterface
|
||||
readonly class TagService implements TagServiceInterface
|
||||
{
|
||||
public function __construct(private readonly ORM\EntityManagerInterface $em)
|
||||
public function __construct(private ORM\EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Tag[]|Paginator
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
@@ -37,7 +36,7 @@ class TagService implements TagServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]|Paginator
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
@@ -46,6 +45,11 @@ class TagService implements TagServiceInterface
|
||||
return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param AdapterInterface<T> $adapter
|
||||
* @return Paginator<T>
|
||||
*/
|
||||
private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
|
||||
{
|
||||
return (new Paginator($adapter))
|
||||
@@ -54,8 +58,7 @@ class TagService implements TagServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
* @throws ForbiddenTagOperationException
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
|
||||
{
|
||||
@@ -69,9 +72,7 @@ class TagService implements TagServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TagNotFoundException
|
||||
* @throws TagConflictException
|
||||
* @throws ForbiddenTagOperationException
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
|
||||
{
|
||||
|
||||
@@ -17,12 +17,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
interface TagServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return Tag[]|Paginator
|
||||
* @return Paginator<Tag>
|
||||
*/
|
||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]|Paginator
|
||||
* @return Paginator<TagInfo>
|
||||
*/
|
||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
|
||||
85
module/Core/src/Util/IpAddressUtils.php
Normal file
85
module/Core/src/Util/IpAddressUtils.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Util;
|
||||
|
||||
use IPLib\Address\IPv4;
|
||||
use IPLib\Factory;
|
||||
use IPLib\Range\RangeInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||
|
||||
use function array_keys;
|
||||
use function array_map;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function str_contains;
|
||||
|
||||
final class IpAddressUtils
|
||||
{
|
||||
public static function isStaticIpCidrOrWildcard(string $candidate): bool
|
||||
{
|
||||
return self::candidateToRange($candidate, ['0', '0', '0', '0']) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP address matches any of provided groups.
|
||||
* Every group can be a static IP address (100.200.80.40), a CIDR block (192.168.10.0/24) or a wildcard pattern
|
||||
* (11.22.*.*).
|
||||
*
|
||||
* Matching will happen as follows:
|
||||
* * Static IP address -> strict equality with provided IP address.
|
||||
* * CIDR block -> provided IP address is part of that block.
|
||||
* * Wildcard pattern -> static parts match the corresponding ones in provided IP address.
|
||||
*
|
||||
* @param string[] $groups
|
||||
* @throws InvalidIpFormatException
|
||||
*/
|
||||
public static function ipAddressMatchesGroups(string $ipAddress, array $groups): bool
|
||||
{
|
||||
$ip = IPv4::parseString($ipAddress);
|
||||
if ($ip === null) {
|
||||
throw InvalidIpFormatException::fromInvalidIp($ipAddress);
|
||||
}
|
||||
|
||||
$ipAddressParts = explode('.', $ipAddress);
|
||||
|
||||
return some($groups, function (string $group) use ($ip, $ipAddressParts): bool {
|
||||
$range = self::candidateToRange($group, $ipAddressParts);
|
||||
return $range !== null && $range->contains($ip);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a static IP, CIDR block or wildcard pattern into a Range object
|
||||
*
|
||||
* @param string[] $ipAddressParts
|
||||
*/
|
||||
private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface
|
||||
{
|
||||
return str_contains($candidate, '*')
|
||||
? self::parseValueWithWildcards($candidate, $ipAddressParts)
|
||||
: Factory::parseRangeString($candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to generate an IP range from a wildcard pattern.
|
||||
* Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also
|
||||
* covers cases where wildcards are in between.
|
||||
*/
|
||||
private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface
|
||||
{
|
||||
$octets = explode('.', $value);
|
||||
$keys = array_keys($octets);
|
||||
|
||||
// Replace wildcard parts with the corresponding ones from the remote address
|
||||
return Factory::parseRangeString(
|
||||
implode('.', array_map(
|
||||
fn (string $part, int $index) => $part === '*' ? $ipAddressParts[$index] : $part,
|
||||
$octets,
|
||||
$keys,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,10 @@ final class OrphanVisitsCountTracker
|
||||
$conn = $em->getConnection();
|
||||
$platformClass = $conn->getDatabasePlatform();
|
||||
|
||||
match ($platformClass::class) {
|
||||
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $isBot),
|
||||
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $isBot),
|
||||
match (true) {
|
||||
$platformClass instanceof PostgreSQLPlatform => $this->incrementForPostgres($conn, $isBot),
|
||||
$platformClass instanceof SQLitePlatform || $platformClass instanceof SQLServerPlatform
|
||||
=> $this->incrementForOthers($conn, $isBot),
|
||||
default => $this->incrementForMySQL($conn, $isBot),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,9 +64,10 @@ final class ShortUrlVisitsCountTracker
|
||||
$conn = $em->getConnection();
|
||||
$platformClass = $conn->getDatabasePlatform();
|
||||
|
||||
match ($platformClass::class) {
|
||||
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $shortUrlId, $isBot),
|
||||
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $shortUrlId, $isBot),
|
||||
match (true) {
|
||||
$platformClass instanceof PostgreSQLPlatform => $this->incrementForPostgres($conn, $shortUrlId, $isBot),
|
||||
$platformClass instanceof SQLitePlatform || $platformClass instanceof SQLServerPlatform
|
||||
=> $this->incrementForOthers($conn, $shortUrlId, $isBot),
|
||||
default => $this->incrementForMySQL($conn, $shortUrlId, $isBot),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
use function Shlinkio\Shlink\Core\isCrawler;
|
||||
use function substr;
|
||||
|
||||
@@ -46,7 +46,7 @@ final class Visitor
|
||||
return new self(
|
||||
$request->getHeaderLine('User-Agent'),
|
||||
$request->getHeaderLine('Referer'),
|
||||
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
|
||||
ipAddressFromRequest($request),
|
||||
$request->getUri()->__toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,23 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/**
|
||||
* @extends AbstractCacheableCountPaginatorAdapter<Visit>
|
||||
*/
|
||||
class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
public function __construct(
|
||||
private VisitRepositoryInterface $visitRepository,
|
||||
private string $domain,
|
||||
private VisitsParams $params,
|
||||
private ?ApiKey $apiKey,
|
||||
private readonly VisitRepositoryInterface $visitRepository,
|
||||
private readonly string $domain,
|
||||
private readonly VisitsParams $params,
|
||||
private readonly ?ApiKey $apiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user