mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-10 17:23:12 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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
|
label: Minimum steps to reproduce
|
||||||
value: |
|
value: |
|
||||||
<!--
|
<!--
|
||||||
Emphasis in MINIMUM: What is the simplest way to reproduce the bug?
|
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 and the bug may be closed as "not reproducible".
|
|
||||||
If you can provide a simple docker compose config, that's even better.
|
* 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
|
ini-values: pcov.directory=module
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: ${{ inputs.install-deps == 'yes' }}
|
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
|
shell: bash
|
||||||
|
|||||||
9
.github/workflows/ci-db-tests.yml
vendored
9
.github/workflows/ci-db-tests.yml
vendored
@@ -10,10 +10,11 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
db-tests:
|
db-tests:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.2', '8.3']
|
php-version: ['8.2', '8.3', '8.4']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||||
env:
|
env:
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
steps:
|
steps:
|
||||||
@@ -31,12 +32,12 @@ jobs:
|
|||||||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||||
- name: Create test database
|
- name: Create test database
|
||||||
if: ${{ inputs.platform == 'ms' }}
|
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
|
- name: Run tests
|
||||||
run: composer test:db:${{ inputs.platform }}
|
run: composer test:db:${{ inputs.platform }}
|
||||||
- name: Upload code coverage
|
- name: Upload code coverage
|
||||||
uses: actions/upload-artifact@v4
|
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:
|
with:
|
||||||
name: coverage-db
|
name: coverage-db
|
||||||
path: |
|
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:
|
jobs:
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
7
.github/workflows/ci-tests.yml
vendored
7
.github/workflows/ci-tests.yml
vendored
@@ -10,10 +10,11 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.2', '8.3']
|
php-version: ['8.2', '8.3', '8.4']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||||
steps:
|
steps:
|
||||||
@@ -33,7 +34,7 @@ jobs:
|
|||||||
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
|
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
|
||||||
- run: composer test:${{ inputs.test-group }}:ci
|
- run: composer test:${{ inputs.test-group }}:ci
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ matrix.php-version == '8.2' }}
|
if: ${{ matrix.php-version == '8.3' }}
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ inputs.test-group }}
|
name: coverage-${{ inputs.test-group }}
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -24,10 +24,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
static-analysis:
|
static-analysis:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.2']
|
php-version: ['8.3']
|
||||||
command: ['cs', 'stan', 'swagger:validate']
|
command: ['cs', 'stan', 'swagger:validate']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -66,10 +66,10 @@ jobs:
|
|||||||
- api-tests
|
- api-tests
|
||||||
- cli-tests
|
- cli-tests
|
||||||
- db-tests
|
- db-tests
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.2']
|
php-version: ['8.3']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
delete-artifacts:
|
delete-artifacts:
|
||||||
needs:
|
needs:
|
||||||
- upload-coverage
|
- upload-coverage
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: geekyeggo/delete-artifact@v2
|
- uses: geekyeggo/delete-artifact@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
8
.github/workflows/publish-release.yml
vendored
8
.github/workflows/publish-release.yml
vendored
@@ -7,10 +7,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.2', '8.3']
|
php-version: ['8.2', '8.3'] # TODO 8.4
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: './.github/actions/ci-setup'
|
- uses: './.github/actions/ci-setup'
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: ['build']
|
needs: ['build']
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
delete-artifacts:
|
delete-artifacts:
|
||||||
needs: ['publish']
|
needs: ['publish']
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: geekyeggo/delete-artifact@v2
|
- uses: geekyeggo/delete-artifact@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/publish-swagger-spec.yml
vendored
2
.github/workflows/publish-swagger-spec.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.2']
|
php-version: ['8.2']
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [4.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
|
## [4.1.1] - 2024-05-23
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ echo 'Starting server...'
|
|||||||
-o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
|
-o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
|
||||||
sleep 2 # Let's give the server a couple of seconds to start
|
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=$?
|
TESTS_EXIT_CODE=$?
|
||||||
|
|
||||||
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w .
|
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w .
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"akrabat/ip-address-middleware": "^2.1",
|
"akrabat/ip-address-middleware": "^2.1",
|
||||||
"cakephp/chronos": "^3.0.2",
|
"cakephp/chronos": "^3.0.2",
|
||||||
"doctrine/dbal": "^4.0",
|
"doctrine/dbal": "^4.1",
|
||||||
"doctrine/migrations": "^3.6",
|
"doctrine/migrations": "^3.6",
|
||||||
"doctrine/orm": "^3.0",
|
"doctrine/orm": "^3.2",
|
||||||
"endroid/qr-code": "^5.0",
|
"endroid/qr-code": "^5.0",
|
||||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||||
"geoip2/geoip2": "^3.0",
|
"geoip2/geoip2": "^3.0",
|
||||||
@@ -44,17 +44,17 @@
|
|||||||
"pagerfanta/core": "^3.8",
|
"pagerfanta/core": "^3.8",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"shlinkio/doctrine-specification": "^2.1.1",
|
"shlinkio/doctrine-specification": "^2.1.1",
|
||||||
"shlinkio/shlink-common": "^6.1",
|
"shlinkio/shlink-common": "^6.3",
|
||||||
"shlinkio/shlink-config": "^3.0",
|
"shlinkio/shlink-config": "^3.0",
|
||||||
"shlinkio/shlink-event-dispatcher": "^4.1",
|
"shlinkio/shlink-event-dispatcher": "^4.1",
|
||||||
"shlinkio/shlink-importer": "^5.3.2",
|
"shlinkio/shlink-importer": "^5.3.2",
|
||||||
"shlinkio/shlink-installer": "^9.1",
|
"shlinkio/shlink-installer": "^9.2",
|
||||||
"shlinkio/shlink-ip-geolocation": "^4.0",
|
"shlinkio/shlink-ip-geolocation": "^4.0",
|
||||||
"shlinkio/shlink-json": "^1.1",
|
"shlinkio/shlink-json": "^1.1",
|
||||||
"spiral/roadrunner": "^2023.3",
|
"spiral/roadrunner": "^2024.1",
|
||||||
"spiral/roadrunner-cli": "^2.6",
|
"spiral/roadrunner-cli": "^2.6",
|
||||||
"spiral/roadrunner-http": "^3.3",
|
"spiral/roadrunner-http": "^3.5",
|
||||||
"spiral/roadrunner-jobs": "^4.3",
|
"spiral/roadrunner-jobs": "^4.5",
|
||||||
"symfony/console": "^7.0",
|
"symfony/console": "^7.0",
|
||||||
"symfony/filesystem": "^7.0",
|
"symfony/filesystem": "^7.0",
|
||||||
"symfony/lock": "^7.0",
|
"symfony/lock": "^7.0",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"phpstan/phpstan-symfony": "^1.4",
|
"phpstan/phpstan-symfony": "^1.4",
|
||||||
"phpunit/php-code-coverage": "^11.0",
|
"phpunit/php-code-coverage": "^11.0",
|
||||||
"phpunit/phpcov": "^10.0",
|
"phpunit/phpcov": "^10.0",
|
||||||
"phpunit/phpunit": "^11.1",
|
"phpunit/phpunit": "^11.3",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"shlinkio/php-coding-standard": "~2.3.0",
|
"shlinkio/php-coding-standard": "~2.3.0",
|
||||||
"shlinkio/shlink-test-utils": "^4.1",
|
"shlinkio/shlink-test-utils": "^4.1",
|
||||||
@@ -114,16 +114,16 @@
|
|||||||
],
|
],
|
||||||
"cs": "phpcs -s",
|
"cs": "phpcs -s",
|
||||||
"cs:fix": "phpcbf",
|
"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": [
|
"test": [
|
||||||
"@parallel test:unit test:db",
|
"@parallel test:unit test:db",
|
||||||
"@parallel test:api test:cli"
|
"@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:ci": "@test:unit --coverage-php=build/coverage-unit.cov",
|
||||||
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
|
"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": "@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:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov",
|
||||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite -- $*",
|
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite -- $*",
|
||||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite -- $*",
|
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite -- $*",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"test:api:mssql": "DB_DRIVER=mssql composer test:api -- $*",
|
"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: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: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: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",
|
"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",
|
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ return [
|
|||||||
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
||||||
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
||||||
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
||||||
|
Option\Robots\RobotsAllowAllShortUrlsConfigOption::class,
|
||||||
|
Option\Robots\RobotsUserAgentsConfigOption::class,
|
||||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||||
|
|||||||
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(false),
|
||||||
|
'user-agents' => splitByComma(Config\EnvVars::ROBOTS_USER_AGENTS->loadFromEnv()),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -4,40 +4,35 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||||
|
|
||||||
return (static function (): array {
|
use function Shlinkio\Shlink\Core\splitByComma;
|
||||||
/** @var string|null $disableTrackingFrom */
|
|
||||||
$disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'tracking' => [
|
'tracking' => [
|
||||||
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
|
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
|
||||||
// This applies only if IP address tracking is enabled
|
// This applies only if IP address tracking is enabled
|
||||||
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
|
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
|
||||||
|
|
||||||
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
|
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
|
||||||
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
|
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
|
||||||
|
|
||||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
||||||
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
|
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
|
||||||
|
|
||||||
// If true, visits will not be tracked at all
|
// If true, visits will not be tracked at all
|
||||||
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
|
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
|
||||||
|
|
||||||
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
||||||
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
|
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
|
||||||
|
|
||||||
// If true, the referrer will not be tracked
|
// If true, the referrer will not be tracked
|
||||||
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
|
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
|
||||||
|
|
||||||
// If true, the user agent will not be tracked
|
// If true, the user agent will not be tracked
|
||||||
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
|
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
|
||||||
|
|
||||||
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
|
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
|
||||||
'disable_tracking_from' => $disableTrackingFrom === null
|
'disable_tracking_from' => splitByComma(EnvVars::DISABLE_TRACKING_FROM->loadFromEnv()),
|
||||||
? []
|
],
|
||||||
: array_map(trim(...), explode(',', $disableTrackingFrom)),
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
];
|
||||||
})();
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ chdir(dirname(__DIR__));
|
|||||||
|
|
||||||
require 'vendor/autoload.php';
|
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
|
// This is one of the first files loaded. Configure the timezone here
|
||||||
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
|
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
|
||||||
|
|
||||||
@@ -25,6 +23,10 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) {
|
|||||||
|
|
||||||
return (static function (): ServiceManager {
|
return (static function (): ServiceManager {
|
||||||
$config = require __DIR__ . '/config.php';
|
$config = require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
// Set memory limit right after loading config, to ensure installer config has been promoted as env vars
|
||||||
|
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv('512M'));
|
||||||
|
|
||||||
$container = new ServiceManager($config['dependencies']);
|
$container = new ServiceManager($config['dependencies']);
|
||||||
$container->setService('config', $config);
|
$container->setService('config', $config);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
shlink_db_mysql:
|
shlink_db_mysql:
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
shlink_php:
|
shlink_php:
|
||||||
user: 1000:1000
|
user: 1000:1000
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
shlink_nginx:
|
shlink_nginx:
|
||||||
container_name: shlink_nginx
|
container_name: shlink_nginx
|
||||||
@@ -79,7 +77,7 @@ services:
|
|||||||
|
|
||||||
shlink_db_postgres:
|
shlink_db_postgres:
|
||||||
container_name: shlink_db_postgres
|
container_name: shlink_db_postgres
|
||||||
image: postgres:12.2-alpine
|
image: postgres:16.3-alpine
|
||||||
ports:
|
ports:
|
||||||
- "5434:5432"
|
- "5434:5432"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -147,7 +145,7 @@ services:
|
|||||||
SERVER_NAME: ":80"
|
SERVER_NAME: ":80"
|
||||||
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
|
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_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:
|
shlink_rabbitmq:
|
||||||
container_name: shlink_rabbitmq
|
container_name: shlink_rabbitmq
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["device", "language", "query-param"],
|
"enum": ["device", "language", "query-param", "ip-address"],
|
||||||
"description": "The type of the condition, which will condition the logic used to match it"
|
"description": "The type of the condition, which will determine the logic used to match it"
|
||||||
},
|
},
|
||||||
"matchKey": {
|
"matchKey": {
|
||||||
"type": ["string", "null"]
|
"type": ["string", "null"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ return [
|
|||||||
'cli' => [
|
'cli' => [
|
||||||
'commands' => [
|
'commands' => [
|
||||||
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
|
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\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
|
||||||
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
|
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
|
||||||
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
|
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ return [
|
|||||||
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
|
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||||
|
Command\ShortUrl\EditShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
||||||
@@ -92,6 +93,7 @@ return [
|
|||||||
ShortUrlStringifier::class,
|
ShortUrlStringifier::class,
|
||||||
UrlShortenerOptions::class,
|
UrlShortenerOptions::class,
|
||||||
],
|
],
|
||||||
|
Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class, ShortUrlStringifier::class],
|
||||||
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
|
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
|
||||||
Command\ShortUrl\ListShortUrlsCommand::class => [
|
Command\ShortUrl\ListShortUrlsCommand::class => [
|
||||||
ShortUrl\ShortUrlListService::class,
|
ShortUrl\ShortUrlListService::class,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
|||||||
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
|
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Paginator<Visit>
|
||||||
|
*/
|
||||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||||
{
|
{
|
||||||
$domain = $input->getArgument('domain');
|
$domain = $input->getArgument('domain');
|
||||||
|
|||||||
@@ -4,24 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
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 Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
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;
|
use function sprintf;
|
||||||
|
|
||||||
class CreateShortUrlCommand extends Command
|
class CreateShortUrlCommand extends Command
|
||||||
@@ -29,6 +23,7 @@ class CreateShortUrlCommand extends Command
|
|||||||
public const NAME = 'short-url:create';
|
public const NAME = 'short-url:create';
|
||||||
|
|
||||||
private ?SymfonyStyle $io;
|
private ?SymfonyStyle $io;
|
||||||
|
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UrlShortenerInterface $urlShortener,
|
private readonly UrlShortenerInterface $urlShortener,
|
||||||
@@ -36,6 +31,7 @@ class CreateShortUrlCommand extends Command
|
|||||||
private readonly UrlShortenerOptions $options,
|
private readonly UrlShortenerOptions $options,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
$this->shortUrlDataInput = new ShortUrlDataInput($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -43,26 +39,11 @@ class CreateShortUrlCommand extends Command
|
|||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->setName(self::NAME)
|
||||||
->setDescription('Generates a short URL for provided long URL and returns it')
|
->setDescription('Generates a short URL for provided long URL and returns it')
|
||||||
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
|
|
||||||
->addOption(
|
->addOption(
|
||||||
'tags',
|
'domain',
|
||||||
't',
|
'd',
|
||||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
|
||||||
'Tags to apply to the new short URL',
|
|
||||||
)
|
|
||||||
->addOption(
|
|
||||||
'valid-since',
|
|
||||||
's',
|
|
||||||
InputOption::VALUE_REQUIRED,
|
InputOption::VALUE_REQUIRED,
|
||||||
'The date from which this short URL will be valid. '
|
'The domain to which this short URL will be attached.',
|
||||||
. '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.',
|
|
||||||
)
|
)
|
||||||
->addOption(
|
->addOption(
|
||||||
'custom-slug',
|
'custom-slug',
|
||||||
@@ -70,30 +51,6 @@ class CreateShortUrlCommand extends Command
|
|||||||
InputOption::VALUE_REQUIRED,
|
InputOption::VALUE_REQUIRED,
|
||||||
'If provided, this slug will be used instead of generating a short code',
|
'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(
|
->addOption(
|
||||||
'short-code-length',
|
'short-code-length',
|
||||||
'l',
|
'l',
|
||||||
@@ -101,16 +58,16 @@ class CreateShortUrlCommand extends Command
|
|||||||
'The length for generated short code (it will be ignored if --custom-slug was provided).',
|
'The length for generated short code (it will be ignored if --custom-slug was provided).',
|
||||||
)
|
)
|
||||||
->addOption(
|
->addOption(
|
||||||
'crawlable',
|
'path-prefix',
|
||||||
'r',
|
'p',
|
||||||
InputOption::VALUE_NONE,
|
InputOption::VALUE_REQUIRED,
|
||||||
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
|
'Prefix to prepend before the generated short code or provided custom slug',
|
||||||
)
|
)
|
||||||
->addOption(
|
->addOption(
|
||||||
'no-forward-query',
|
'find-if-exists',
|
||||||
'w',
|
'f',
|
||||||
InputOption::VALUE_NONE,
|
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
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = $this->getIO($input, $output);
|
$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 {
|
try {
|
||||||
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
|
||||||
ShortUrlInputFilter::LONG_URL => $longUrl,
|
$input,
|
||||||
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
|
$this->options,
|
||||||
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
|
customSlugField: 'custom-slug',
|
||||||
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
shortCodeLengthField: 'short-code-length',
|
||||||
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
|
pathPrefixField: 'path-prefix',
|
||||||
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
|
findIfExistsField: 'find-if-exists',
|
||||||
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
|
domainField: 'domain',
|
||||||
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->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
|
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
|
||||||
'Short URL properly created, but the real-time updates cannot be notified when generating the '
|
'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([
|
$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)),
|
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
|
||||||
]);
|
]);
|
||||||
return ExitCode::EXIT_SUCCESS;
|
return ExitCode::EXIT_SUCCESS;
|
||||||
@@ -181,6 +123,6 @@ class CreateShortUrlCommand extends Command
|
|||||||
|
|
||||||
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
|
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
|
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||||
{
|
{
|
||||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
$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\ExitCode;
|
||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
@@ -32,8 +32,6 @@ use function sprintf;
|
|||||||
|
|
||||||
class ListShortUrlsCommand extends Command
|
class ListShortUrlsCommand extends Command
|
||||||
{
|
{
|
||||||
use PagerfantaUtilsTrait;
|
|
||||||
|
|
||||||
public const NAME = 'short-url:list';
|
public const NAME = 'short-url:list';
|
||||||
|
|
||||||
private readonly StartDateOption $startDateOption;
|
private readonly StartDateOption $startDateOption;
|
||||||
@@ -41,7 +39,7 @@ class ListShortUrlsCommand extends Command
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ShortUrlListServiceInterface $shortUrlService,
|
private readonly ShortUrlListServiceInterface $shortUrlService,
|
||||||
private readonly DataTransformerInterface $transformer,
|
private readonly ShortUrlDataTransformerInterface $transformer,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->startDateOption = new StartDateOption($this, 'short URLs');
|
$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
|
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
|
||||||
|
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||||
*/
|
*/
|
||||||
private function renderPage(
|
private function renderPage(
|
||||||
OutputInterface $output,
|
OutputInterface $output,
|
||||||
@@ -196,7 +195,7 @@ class ListShortUrlsCommand extends Command
|
|||||||
ShlinkTable::default($output)->render(
|
ShlinkTable::default($output)->render(
|
||||||
array_keys($columnsMap),
|
array_keys($columnsMap),
|
||||||
$rows,
|
$rows,
|
||||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
$all ? null : PagerfantaUtils::formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $shortUrls;
|
return $shortUrls;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
|
|||||||
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
|
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Paginator<Visit>
|
||||||
|
*/
|
||||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||||
{
|
{
|
||||||
$tag = $input->getArgument('tag');
|
$tag = $input->getArgument('tag');
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ abstract class AbstractVisitsListCommand extends Command
|
|||||||
return ExitCode::EXIT_SUCCESS;
|
return ExitCode::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Paginator<Visit> $paginator
|
||||||
|
*/
|
||||||
private function resolveRowsAndHeaders(Paginator $paginator): array
|
private function resolveRowsAndHeaders(Paginator $paginator): array
|
||||||
{
|
{
|
||||||
$extraKeys = [];
|
$extraKeys = [];
|
||||||
@@ -74,6 +77,9 @@ abstract class AbstractVisitsListCommand extends Command
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Paginator<Visit>
|
||||||
|
*/
|
||||||
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
|
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.');
|
->setDescription('Returns the list of non-orphan visits.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Paginator<Visit>
|
||||||
|
*/
|
||||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||||
{
|
{
|
||||||
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
|
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
|
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||||
{
|
{
|
||||||
$rawType = $input->getOption('type');
|
$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->askMandatory('Query param name?', $io),
|
||||||
$this->askOptional('Query param value?', $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?');
|
$continue = $io->confirm('Do you want to add another condition?');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
|
|||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\DBAL\Driver;
|
use Doctrine\DBAL\Driver;
|
||||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||||
|
use Doctrine\DBAL\Platforms\SQLitePlatform;
|
||||||
use Doctrine\DBAL\Schema\AbstractSchemaManager;
|
use Doctrine\DBAL\Schema\AbstractSchemaManager;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
@@ -31,6 +32,7 @@ class CreateDatabaseCommandTest extends TestCase
|
|||||||
private MockObject & ProcessRunnerInterface $processHelper;
|
private MockObject & ProcessRunnerInterface $processHelper;
|
||||||
private MockObject & Connection $regularConn;
|
private MockObject & Connection $regularConn;
|
||||||
private MockObject & ClassMetadataFactory $metadataFactory;
|
private MockObject & ClassMetadataFactory $metadataFactory;
|
||||||
|
/** @var MockObject&AbstractSchemaManager<SQLitePlatform> */
|
||||||
private MockObject & AbstractSchemaManager $schemaManager;
|
private MockObject & AbstractSchemaManager $schemaManager;
|
||||||
private MockObject & Driver $driver;
|
private MockObject & Driver $driver;
|
||||||
|
|
||||||
|
|||||||
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',
|
'Language to match?' => 'en-US',
|
||||||
'Query param name?' => 'foo',
|
'Query param name?' => 'foo',
|
||||||
'Query param value?' => 'bar',
|
'Query param value?' => 'bar',
|
||||||
|
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
|
||||||
default => '',
|
default => '',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
|
|||||||
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
|
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||||
true,
|
true,
|
||||||
];
|
];
|
||||||
|
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ return [
|
|||||||
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
|
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
|
||||||
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
|
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
|
||||||
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
|
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
|
||||||
|
Options\RobotsOptions::class => [ValinorConfigFactory::class, 'config.robots'],
|
||||||
|
|
||||||
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
|
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
|
||||||
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
|
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
|
||||||
@@ -189,7 +190,7 @@ return [
|
|||||||
'Logger_Shlink',
|
'Logger_Shlink',
|
||||||
Options\QrCodeOptions::class,
|
Options\QrCodeOptions::class,
|
||||||
],
|
],
|
||||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Options\RobotsOptions::class],
|
||||||
|
|
||||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
|
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
|
||||||
'em',
|
'em',
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
|||||||
use Laminas\Filter\Word\CamelCaseToSeparator;
|
use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||||
use Laminas\InputFilter\InputFilter;
|
use Laminas\InputFilter\InputFilter;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||||
|
|
||||||
@@ -107,7 +109,6 @@ function normalizeLocale(string $locale): string
|
|||||||
* minimum quality
|
* minimum quality
|
||||||
*
|
*
|
||||||
* @param non-empty-string $acceptLanguage
|
* @param non-empty-string $acceptLanguage
|
||||||
* @param float<0, 1> $minQuality
|
|
||||||
* @return iterable<string>;
|
* @return iterable<string>;
|
||||||
*/
|
*/
|
||||||
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
|
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
|
||||||
@@ -140,21 +141,31 @@ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0):
|
|||||||
*/
|
*/
|
||||||
function splitLocale(string $locale): array
|
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
|
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||||
{
|
{
|
||||||
$value = $inputFilter->getValue($fieldName);
|
$value = $inputFilter->getValue($fieldName);
|
||||||
return $value !== null ? (int) $value : null;
|
return $value !== null ? (int) $value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param InputFilter<mixed> $inputFilter
|
||||||
|
*/
|
||||||
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
|
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
|
||||||
{
|
{
|
||||||
$value = $inputFilter->getValue($fieldName);
|
$value = $inputFilter->getValue($fieldName);
|
||||||
return $value !== null ? (bool) $value : null;
|
return $value !== null ? (bool) $value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param InputFilter<mixed> $inputFilter
|
||||||
|
*/
|
||||||
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
|
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
|
||||||
{
|
{
|
||||||
$value = $inputFilter->getValue($fieldName);
|
$value = $inputFilter->getValue($fieldName);
|
||||||
@@ -260,3 +271,21 @@ function enumToString(string $enum): string
|
|||||||
{
|
{
|
||||||
return sprintf('["%s"]', implode('", "', enumValues($enum)));
|
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\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
|
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Options\RobotsOptions;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
use const PHP_EOL;
|
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:
|
# For more information about the robots.txt standard, see:
|
||||||
# https://www.robotstxt.org/orig.html
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
|
|
||||||
ROBOTS;
|
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();
|
$shortCodes = $this->crawlingHelper->listCrawlableShortCodes();
|
||||||
foreach ($shortCodes as $shortCode) {
|
foreach ($shortCodes as $shortCode) {
|
||||||
yield sprintf('Allow: /%s%s', $shortCode, PHP_EOL);
|
yield sprintf('Allow: /%s%s', $shortCode, PHP_EOL);
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ enum EnvVars: string
|
|||||||
case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
|
case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
|
||||||
case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
|
case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
|
||||||
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
|
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
|
||||||
case TIMEZONE = 'TIMEZONE';
|
|
||||||
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
|
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 MEMORY_LIMIT = 'MEMORY_LIMIT';
|
||||||
|
|
||||||
public function loadFromEnv(mixed $default = null): mixed
|
public function loadFromEnv(mixed $default = null): mixed
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
|
|||||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<Domain> */
|
||||||
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
|
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\Core\Domain\Entity\Domain;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends ObjectRepository<Domain> */
|
||||||
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Laminas\InputFilter\InputFilter;
|
|||||||
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
|
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
|
||||||
use Shlinkio\Shlink\Common\Validation\InputFactory;
|
use Shlinkio\Shlink\Common\Validation\InputFactory;
|
||||||
|
|
||||||
|
/** @extends InputFilter<mixed> */
|
||||||
class DomainRedirectsInputFilter extends InputFilter
|
class DomainRedirectsInputFilter extends InputFilter
|
||||||
{
|
{
|
||||||
public const DOMAIN = 'domain';
|
public const DOMAIN = 'domain';
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
|
||||||
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
|
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
|
|
||||||
final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface
|
final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface
|
||||||
{
|
{
|
||||||
public function __construct(private DataTransformerInterface $shortUrlTransformer)
|
public function __construct(private ShortUrlDataTransformerInterface $shortUrlTransformer)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function newVisitUpdate(Visit $visit): Update
|
public function newVisitUpdate(Visit $visit): Update
|
||||||
{
|
{
|
||||||
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
|
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
|
||||||
'shortUrl' => $this->shortUrlTransformer->transform($visit->shortUrl),
|
'shortUrl' => $this->transformShortUrl($visit->shortUrl),
|
||||||
'visit' => $visit->jsonSerialize(),
|
'visit' => $visit->jsonSerialize(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
|
|||||||
$topic = Topic::newShortUrlVisit($shortUrl?->getShortCode());
|
$topic = Topic::newShortUrlVisit($shortUrl?->getShortCode());
|
||||||
|
|
||||||
return Update::forTopicAndPayload($topic, [
|
return Update::forTopicAndPayload($topic, [
|
||||||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
'shortUrl' => $this->transformShortUrl($shortUrl),
|
||||||
'visit' => $visit->jsonSerialize(),
|
'visit' => $visit->jsonSerialize(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -47,4 +47,9 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
|
|||||||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
'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;
|
private array $invalidElements;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param InputFilterInterface<mixed> $inputFilter
|
||||||
|
*/
|
||||||
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
|
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
|
||||||
{
|
{
|
||||||
return static::fromArray($inputFilter->getMessages(), $prev);
|
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;
|
use Pagerfanta\Adapter\AdapterInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @implements AdapterInterface<T>
|
||||||
|
*/
|
||||||
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
|
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
|
||||||
{
|
{
|
||||||
private ?int $count = null;
|
private ?int $count = null;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
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\acceptLanguageToLocales;
|
||||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||||
|
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||||
use function Shlinkio\Shlink\Core\normalizeLocale;
|
use function Shlinkio\Shlink\Core\normalizeLocale;
|
||||||
use function Shlinkio\Shlink\Core\splitLocale;
|
use function Shlinkio\Shlink\Core\splitLocale;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
@@ -41,6 +43,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||||||
return new self(RedirectConditionType::DEVICE, $device->value);
|
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
|
public static function fromRawData(array $rawData): self
|
||||||
{
|
{
|
||||||
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
|
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
|
||||||
@@ -59,6 +70,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||||||
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
|
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
|
||||||
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
|
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
|
||||||
RedirectConditionType::DEVICE => $this->matchesDevice($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);
|
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
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -119,6 +137,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||||||
$this->matchKey,
|
$this->matchKey,
|
||||||
$this->matchValue,
|
$this->matchValue,
|
||||||
),
|
),
|
||||||
|
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ enum RedirectConditionType: string
|
|||||||
case DEVICE = 'device';
|
case DEVICE = 'device';
|
||||||
case LANGUAGE = 'language';
|
case LANGUAGE = 'language';
|
||||||
case QUERY_PARAM = 'query-param';
|
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\Model\DeviceType;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
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\ArrayUtils\contains;
|
||||||
use function Shlinkio\Shlink\Core\enumValues;
|
use function Shlinkio\Shlink\Core\enumValues;
|
||||||
|
|
||||||
|
/** @extends InputFilter<mixed> */
|
||||||
class RedirectRulesInputFilter extends InputFilter
|
class RedirectRulesInputFilter extends InputFilter
|
||||||
{
|
{
|
||||||
public const REDIRECT_RULES = 'redirectRules';
|
public const REDIRECT_RULES = 'redirectRules';
|
||||||
@@ -43,6 +45,9 @@ class RedirectRulesInputFilter extends InputFilter
|
|||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return InputFilter<mixed>
|
||||||
|
*/
|
||||||
private static function createRedirectRuleInputFilter(): InputFilter
|
private static function createRedirectRuleInputFilter(): InputFilter
|
||||||
{
|
{
|
||||||
$redirectRuleInputFilter = new InputFilter();
|
$redirectRuleInputFilter = new InputFilter();
|
||||||
@@ -59,6 +64,9 @@ class RedirectRulesInputFilter extends InputFilter
|
|||||||
return $redirectRuleInputFilter;
|
return $redirectRuleInputFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return InputFilter<mixed>
|
||||||
|
*/
|
||||||
private static function createRedirectConditionInputFilter(): InputFilter
|
private static function createRedirectConditionInputFilter(): InputFilter
|
||||||
{
|
{
|
||||||
$redirectConditionInputFilter = new InputFilter();
|
$redirectConditionInputFilter = new InputFilter();
|
||||||
@@ -71,13 +79,14 @@ class RedirectRulesInputFilter extends InputFilter
|
|||||||
$redirectConditionInputFilter->add($type);
|
$redirectConditionInputFilter->add($type);
|
||||||
|
|
||||||
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
|
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
|
||||||
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
|
$value->getValidatorChain()->attach(new Callback(
|
||||||
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
|
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
|
||||||
return contains($value, enumValues(DeviceType::class));
|
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
|
||||||
}
|
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
|
||||||
|
// RedirectConditionType::LANGUAGE->value => TODO,
|
||||||
return true;
|
default => true,
|
||||||
}));
|
},
|
||||||
|
));
|
||||||
$redirectConditionInputFilter->add($value);
|
$redirectConditionInputFilter->add($value);
|
||||||
|
|
||||||
$redirectConditionInputFilter->add(
|
$redirectConditionInputFilter->add(
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ class ShortUrl extends AbstractEntity
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tag> $tags
|
* @param Collection<int, Tag> $tags
|
||||||
* @param Collection<int, Visit> & Selectable $visits
|
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
|
||||||
* @param Collection<int, ShortUrlVisitsCount> & Selectable $visitsCounts
|
* @param Collection<int, ShortUrlVisitsCount> & Selectable<int, ShortUrlVisitsCount> $visitsCounts
|
||||||
*/
|
*/
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private string $longUrl,
|
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
|
* @internal
|
||||||
*/
|
*/
|
||||||
public function setVisits(Collection & Selectable $visits): self
|
public function setVisits(Collection & Selectable $visits): self
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use function substr;
|
|||||||
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
|
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
|
||||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
|
/** @extends InputFilter<mixed> */
|
||||||
class ShortUrlInputFilter extends InputFilter
|
class ShortUrlInputFilter extends InputFilter
|
||||||
{
|
{
|
||||||
// Fields for creation only
|
// Fields for creation only
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
|||||||
|
|
||||||
use function Shlinkio\Shlink\Core\enumValues;
|
use function Shlinkio\Shlink\Core\enumValues;
|
||||||
|
|
||||||
|
/** @extends InputFilter<mixed> */
|
||||||
class ShortUrlsParamsInputFilter extends InputFilter
|
class ShortUrlsParamsInputFilter extends InputFilter
|
||||||
{
|
{
|
||||||
public const PAGE = 'page';
|
public const PAGE = 'page';
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter;
|
|||||||
|
|
||||||
use Pagerfanta\Adapter\AdapterInterface;
|
use Pagerfanta\Adapter\AdapterInterface;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
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\ShortUrlsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
|
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @implements AdapterInterface<ShortUrlWithVisitsSummary> */
|
||||||
readonly class ShortUrlRepositoryAdapter implements AdapterInterface
|
readonly class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
|
|||||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||||
class CrawlableShortCodesQuery extends EntitySpecificationRepository implements CrawlableShortCodesQueryInterface
|
class CrawlableShortCodesQuery extends EntitySpecificationRepository implements CrawlableShortCodesQueryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||||
class ExpiredShortUrlsRepository extends EntitySpecificationRepository implements ExpiredShortUrlsRepositoryInterface
|
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 Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||||
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
|
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
|||||||
use function count;
|
use function count;
|
||||||
use function strtolower;
|
use function strtolower;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||||
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
|
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl
|
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\Core\ShortUrl\Model\ShortUrlMode;
|
||||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||||
|
|
||||||
|
/** @extends ObjectRepository<ShortUrl> */
|
||||||
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl;
|
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\Common\Paginator\Paginator;
|
||||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
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\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
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
|
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|||||||
interface ShortUrlListServiceInterface
|
interface ShortUrlListServiceInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return ShortUrlWithVisitsSummary[]|Paginator
|
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||||
*/
|
*/
|
||||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
|
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||||
|
|
||||||
/**
|
readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface
|
||||||
* @fixme Do not implement DataTransformerInterface, but a separate interface
|
|
||||||
*/
|
|
||||||
readonly class ShortUrlDataTransformer implements DataTransformerInterface
|
|
||||||
{
|
{
|
||||||
public function __construct(private ShortUrlStringifierInterface $stringifier)
|
public function __construct(private ShortUrlStringifierInterface $stringifier)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array
|
||||||
* @param ShortUrlWithVisitsSummary|ShortUrl $data
|
|
||||||
*/
|
|
||||||
public function transform($data): array // phpcs:ignore
|
|
||||||
{
|
{
|
||||||
$shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data;
|
$shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data;
|
||||||
return [
|
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 Doctrine\Common\Collections;
|
||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
class Tag extends AbstractEntity implements JsonSerializable
|
class Tag extends AbstractEntity implements JsonSerializable
|
||||||
{
|
{
|
||||||
|
/** @var Collections\Collection<int, ShortUrl> */
|
||||||
private Collections\Collection $shortUrls;
|
private Collections\Collection $shortUrls;
|
||||||
|
|
||||||
public function __construct(private string $name)
|
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\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @implements AdapterInterface<T>
|
||||||
|
*/
|
||||||
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
|
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||||
|
|
||||||
|
/** @extends AbstractTagsPaginatorAdapter<TagInfo> */
|
||||||
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function getSlice(int $offset, int $length): iterable
|
public function getSlice(int $offset, int $length): iterable
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||||
|
|
||||||
use Happyr\DoctrineSpecification\Spec;
|
use Happyr\DoctrineSpecification\Spec;
|
||||||
|
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||||
|
|
||||||
|
/** @extends AbstractTagsPaginatorAdapter<Tag> */
|
||||||
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function getSlice(int $offset, int $length): iterable
|
public function getSlice(int $offset, int $length): iterable
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
|
|||||||
|
|
||||||
use const PHP_INT_MAX;
|
use const PHP_INT_MAX;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<Tag> */
|
||||||
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
|
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
|
||||||
{
|
{
|
||||||
public function deleteByName(array $names): int
|
public function deleteByName(array $names): int
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\Core\Tag\Repository;
|
|||||||
|
|
||||||
use Doctrine\Persistence\ObjectRepository;
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
|
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\TagInfo;
|
||||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends ObjectRepository<Tag> */
|
||||||
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||||
{
|
{
|
||||||
public function deleteByName(array $names): int;
|
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\TagConflictException;
|
||||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
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\TagRenaming;
|
||||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
|
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\Core\Tag\Repository\TagRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
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
|
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
|
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);
|
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
|
private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
|
||||||
{
|
{
|
||||||
return (new Paginator($adapter))
|
return (new Paginator($adapter))
|
||||||
@@ -54,8 +58,7 @@ class TagService implements TagServiceInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string[] $tagNames
|
* @inheritDoc
|
||||||
* @throws ForbiddenTagOperationException
|
|
||||||
*/
|
*/
|
||||||
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
|
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
|
||||||
{
|
{
|
||||||
@@ -69,9 +72,7 @@ class TagService implements TagServiceInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws TagNotFoundException
|
* @inheritDoc
|
||||||
* @throws TagConflictException
|
|
||||||
* @throws ForbiddenTagOperationException
|
|
||||||
*/
|
*/
|
||||||
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
|
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|||||||
interface TagServiceInterface
|
interface TagServiceInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return Tag[]|Paginator
|
* @return Paginator<Tag>
|
||||||
*/
|
*/
|
||||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return TagInfo[]|Paginator
|
* @return Paginator<TagInfo>
|
||||||
*/
|
*/
|
||||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
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();
|
$conn = $em->getConnection();
|
||||||
$platformClass = $conn->getDatabasePlatform();
|
$platformClass = $conn->getDatabasePlatform();
|
||||||
|
|
||||||
match ($platformClass::class) {
|
match (true) {
|
||||||
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $isBot),
|
$platformClass instanceof PostgreSQLPlatform => $this->incrementForPostgres($conn, $isBot),
|
||||||
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $isBot),
|
$platformClass instanceof SQLitePlatform || $platformClass instanceof SQLServerPlatform
|
||||||
|
=> $this->incrementForOthers($conn, $isBot),
|
||||||
default => $this->incrementForMySQL($conn, $isBot),
|
default => $this->incrementForMySQL($conn, $isBot),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,10 @@ final class ShortUrlVisitsCountTracker
|
|||||||
$conn = $em->getConnection();
|
$conn = $em->getConnection();
|
||||||
$platformClass = $conn->getDatabasePlatform();
|
$platformClass = $conn->getDatabasePlatform();
|
||||||
|
|
||||||
match ($platformClass::class) {
|
match (true) {
|
||||||
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $shortUrlId, $isBot),
|
$platformClass instanceof PostgreSQLPlatform => $this->incrementForPostgres($conn, $shortUrlId, $isBot),
|
||||||
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $shortUrlId, $isBot),
|
$platformClass instanceof SQLitePlatform || $platformClass instanceof SQLServerPlatform
|
||||||
|
=> $this->incrementForOthers($conn, $shortUrlId, $isBot),
|
||||||
default => $this->incrementForMySQL($conn, $shortUrlId, $isBot),
|
default => $this->incrementForMySQL($conn, $shortUrlId, $isBot),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||||
|
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
|
||||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||||
use function Shlinkio\Shlink\Core\isCrawler;
|
use function Shlinkio\Shlink\Core\isCrawler;
|
||||||
use function substr;
|
use function substr;
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ final class Visitor
|
|||||||
return new self(
|
return new self(
|
||||||
$request->getHeaderLine('User-Agent'),
|
$request->getHeaderLine('User-Agent'),
|
||||||
$request->getHeaderLine('Referer'),
|
$request->getHeaderLine('Referer'),
|
||||||
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
|
ipAddressFromRequest($request),
|
||||||
$request->getUri()->__toString(),
|
$request->getUri()->__toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,23 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
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\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends AbstractCacheableCountPaginatorAdapter<Visit>
|
||||||
|
*/
|
||||||
class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitRepositoryInterface $visitRepository,
|
private readonly VisitRepositoryInterface $visitRepository,
|
||||||
private string $domain,
|
private readonly string $domain,
|
||||||
private VisitsParams $params,
|
private readonly VisitsParams $params,
|
||||||
private ?ApiKey $apiKey,
|
private readonly ?ApiKey $apiKey,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
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\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
|
||||||
class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitRepositoryInterface $repo,
|
private readonly VisitRepositoryInterface $repo,
|
||||||
private VisitsParams $params,
|
private readonly VisitsParams $params,
|
||||||
private ?ApiKey $apiKey,
|
private readonly ?ApiKey $apiKey,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
|
||||||
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
|||||||
|
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
|
||||||
class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitRepositoryInterface $visitRepository,
|
private readonly VisitRepositoryInterface $visitRepository,
|
||||||
private ShortUrlIdentifier $identifier,
|
private readonly ShortUrlIdentifier $identifier,
|
||||||
private VisitsParams $params,
|
private readonly VisitsParams $params,
|
||||||
private ?ApiKey $apiKey,
|
private readonly ?ApiKey $apiKey,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
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\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
|
||||||
class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitRepositoryInterface $visitRepository,
|
private readonly VisitRepositoryInterface $visitRepository,
|
||||||
private string $tag,
|
private readonly string $tag,
|
||||||
private VisitsParams $params,
|
private readonly VisitsParams $params,
|
||||||
private ?ApiKey $apiKey,
|
private readonly ?ApiKey $apiKey,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
|
|||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<OrphanVisitsCount> */
|
||||||
class OrphanVisitsCountRepository extends EntitySpecificationRepository implements OrphanVisitsCountRepositoryInterface
|
class OrphanVisitsCountRepository extends EntitySpecificationRepository implements OrphanVisitsCountRepositoryInterface
|
||||||
{
|
{
|
||||||
public function countOrphanVisits(VisitsCountFiltering $filtering): int
|
public function countOrphanVisits(VisitsCountFiltering $filtering): int
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit\Repository;
|
namespace Shlinkio\Shlink\Core\Visit\Repository;
|
||||||
|
|
||||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||||
class ShortUrlVisitsCountRepository extends EntitySpecificationRepository implements
|
class ShortUrlVisitsCountRepository extends EntitySpecificationRepository implements
|
||||||
ShortUrlVisitsCountRepositoryInterface
|
ShortUrlVisitsCountRepositoryInterface
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
|||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<Visit> */
|
||||||
class VisitDeleterRepository extends EntitySpecificationRepository implements VisitDeleterRepositoryInterface
|
class VisitDeleterRepository extends EntitySpecificationRepository implements VisitDeleterRepositoryInterface
|
||||||
{
|
{
|
||||||
public function deleteShortUrlVisits(ShortUrl $shortUrl): int
|
public function deleteShortUrlVisits(ShortUrl $shortUrl): int
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows iterating large amounts of visits in a memory-efficient way, to use in batch processes
|
* Allows iterating large amounts of visits in a memory-efficient way, to use in batch processes
|
||||||
|
* @extends EntitySpecificationRepository<Visit>
|
||||||
*/
|
*/
|
||||||
class VisitIterationRepository extends EntitySpecificationRepository implements VisitIterationRepositoryInterface
|
class VisitIterationRepository extends EntitySpecificationRepository implements VisitIterationRepositoryInterface
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use Shlinkio\Shlink\Rest\ApiKey\Role;
|
|||||||
|
|
||||||
use const PHP_INT_MAX;
|
use const PHP_INT_MAX;
|
||||||
|
|
||||||
|
/** @extends EntitySpecificationRepository<Visit> */
|
||||||
class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
|
class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
|||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ObjectRepository<Visit>
|
||||||
|
*/
|
||||||
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,30 +5,21 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Visit;
|
namespace Shlinkio\Shlink\Core\Visit;
|
||||||
|
|
||||||
use Fig\Http\Message\RequestMethodInterface;
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
use IPLib\Address\IPv4;
|
|
||||||
use IPLib\Factory;
|
|
||||||
use IPLib\Range\RangeInterface;
|
|
||||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
|
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||||
|
|
||||||
use function array_keys;
|
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||||
use function array_map;
|
|
||||||
use function explode;
|
|
||||||
use function implode;
|
|
||||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
|
||||||
use function str_contains;
|
|
||||||
|
|
||||||
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions)
|
||||||
private readonly VisitsTrackerInterface $visitsTracker,
|
{
|
||||||
private readonly TrackingOptions $trackingOptions,
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void
|
public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void
|
||||||
@@ -63,7 +54,7 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
$remoteAddr = ipAddressFromRequest($request);
|
||||||
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
|
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -78,35 +69,10 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ip = IPv4::parseString($remoteAddr);
|
try {
|
||||||
if ($ip === null) {
|
return IpAddressUtils::ipAddressMatchesGroups($remoteAddr, $this->trackingOptions->disableTrackingFrom);
|
||||||
|
} catch (InvalidIpFormatException) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteAddrParts = explode('.', $remoteAddr);
|
|
||||||
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom;
|
|
||||||
|
|
||||||
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
|
|
||||||
$range = str_contains($value, '*')
|
|
||||||
? $this->parseValueWithWildcards($value, $remoteAddrParts)
|
|
||||||
: Factory::parseRangeString($value);
|
|
||||||
|
|
||||||
return $range !== null && $ip->matches($range);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?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 === '*' ? $remoteAddrParts[$index] : $part,
|
|
||||||
$octets,
|
|
||||||
$keys,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @inheritDoc
|
||||||
* @throws ShortUrlNotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function visitsForShortUrl(
|
public function visitsForShortUrl(
|
||||||
ShortUrlIdentifier $identifier,
|
ShortUrlIdentifier $identifier,
|
||||||
@@ -87,8 +86,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @inheritDoc
|
||||||
* @throws TagNotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||||
{
|
{
|
||||||
@@ -105,8 +103,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @inheritDoc
|
||||||
* @throws DomainNotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||||
{
|
{
|
||||||
@@ -123,7 +120,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||||
{
|
{
|
||||||
@@ -141,6 +138,10 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||||||
return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
|
return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AdapterInterface<Visit> $adapter
|
||||||
|
* @return Paginator<Visit>
|
||||||
|
*/
|
||||||
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
|
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
|
||||||
{
|
{
|
||||||
$paginator = new Paginator($adapter);
|
$paginator = new Paginator($adapter);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface VisitsStatsHelperInterface
|
|||||||
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
|
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @return Paginator<Visit>
|
||||||
* @throws ShortUrlNotFoundException
|
* @throws ShortUrlNotFoundException
|
||||||
*/
|
*/
|
||||||
public function visitsForShortUrl(
|
public function visitsForShortUrl(
|
||||||
@@ -30,24 +30,24 @@ interface VisitsStatsHelperInterface
|
|||||||
): Paginator;
|
): Paginator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @return Paginator<Visit>
|
||||||
* @throws TagNotFoundException
|
* @throws TagNotFoundException
|
||||||
*/
|
*/
|
||||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @return Paginator<Visit>
|
||||||
* @throws DomainNotFoundException
|
* @throws DomainNotFoundException
|
||||||
*/
|
*/
|
||||||
public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @return Paginator<Visit>
|
||||||
*/
|
*/
|
||||||
public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]|Paginator
|
* @return Paginator<Visit>
|
||||||
*/
|
*/
|
||||||
public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\Test;
|
|||||||
use PHPUnit\Framework\Attributes\TestWith;
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
|
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
|
||||||
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
|
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
|
||||||
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
|
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
|
||||||
@@ -86,6 +88,16 @@ class RedirectTest extends ApiTestCase
|
|||||||
],
|
],
|
||||||
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$clientDetection = require __DIR__ . '/../../../../config/autoload/client-detection.global.php';
|
||||||
|
foreach ($clientDetection['ip_address_resolution']['headers_to_inspect'] as $header) {
|
||||||
|
yield sprintf('rule: IP address in "%s" header', $header) => [
|
||||||
|
[
|
||||||
|
RequestOptions::HEADERS => [$header => '1.2.3.4'],
|
||||||
|
],
|
||||||
|
'https://example.com/static-ip-address',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;
|
namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
|
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
|
||||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||||
|
|
||||||
use function array_filter;
|
use function array_filter;
|
||||||
@@ -16,7 +16,7 @@ use function array_values;
|
|||||||
|
|
||||||
class OrphanVisitsCountTrackerTest extends DatabaseTestCase
|
class OrphanVisitsCountTrackerTest extends DatabaseTestCase
|
||||||
{
|
{
|
||||||
private EntityRepository $repo;
|
private OrphanVisitsCountRepository $repo;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;
|
namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
|
||||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||||
|
|
||||||
use function array_filter;
|
use function array_filter;
|
||||||
@@ -17,7 +17,7 @@ use function array_values;
|
|||||||
|
|
||||||
class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase
|
class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase
|
||||||
{
|
{
|
||||||
private EntityRepository $repo;
|
private ShortUrlVisitsCountRepository $repo;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,27 +11,29 @@ use PHPUnit\Framework\MockObject\MockObject;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Shlinkio\Shlink\Core\Action\RobotsAction;
|
use Shlinkio\Shlink\Core\Action\RobotsAction;
|
||||||
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
|
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Options\RobotsOptions;
|
||||||
|
|
||||||
class RobotsActionTest extends TestCase
|
class RobotsActionTest extends TestCase
|
||||||
{
|
{
|
||||||
private RobotsAction $action;
|
|
||||||
private MockObject & CrawlingHelperInterface $helper;
|
private MockObject & CrawlingHelperInterface $helper;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->helper = $this->createMock(CrawlingHelperInterface::class);
|
$this->helper = $this->createMock(CrawlingHelperInterface::class);
|
||||||
$this->action = new RobotsAction($this->helper);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test, DataProvider('provideShortCodes')]
|
#[Test, DataProvider('provideShortCodes')]
|
||||||
public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void
|
public function buildsRobotsLinesFromCrawlableShortCodes(
|
||||||
{
|
array $shortCodes,
|
||||||
|
RobotsOptions $options,
|
||||||
|
string $expected,
|
||||||
|
): void {
|
||||||
$this->helper
|
$this->helper
|
||||||
->expects($this->once())
|
->expects($options->allowAllShortUrls ? $this->never() : $this->once())
|
||||||
->method('listCrawlableShortCodes')
|
->method('listCrawlableShortCodes')
|
||||||
->willReturn($shortCodes);
|
->willReturn($shortCodes);
|
||||||
|
|
||||||
$response = $this->action->handle(ServerRequestFactory::fromGlobals());
|
$response = $this->action($options)->handle(ServerRequestFactory::fromGlobals());
|
||||||
|
|
||||||
self::assertEquals(200, $response->getStatusCode());
|
self::assertEquals(200, $response->getStatusCode());
|
||||||
self::assertEquals($expected, $response->getBody()->__toString());
|
self::assertEquals($expected, $response->getBody()->__toString());
|
||||||
@@ -40,7 +42,7 @@ class RobotsActionTest extends TestCase
|
|||||||
|
|
||||||
public static function provideShortCodes(): iterable
|
public static function provideShortCodes(): iterable
|
||||||
{
|
{
|
||||||
yield 'three short codes' => [['foo', 'bar', 'baz'], <<<ROBOTS
|
yield 'three short codes' => [['foo', 'bar', 'baz'], new RobotsOptions(), <<<ROBOTS
|
||||||
# For more information about the robots.txt standard, see:
|
# For more information about the robots.txt standard, see:
|
||||||
# https://www.robotstxt.org/orig.html
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ class RobotsActionTest extends TestCase
|
|||||||
Allow: /baz
|
Allow: /baz
|
||||||
Disallow: /
|
Disallow: /
|
||||||
ROBOTS];
|
ROBOTS];
|
||||||
yield 'five short codes' => [['foo', 'bar', 'some', 'thing', 'baz'], <<<ROBOTS
|
yield 'five short codes' => [['foo', 'bar', 'some', 'thing', 'baz'], new RobotsOptions(), <<<ROBOTS
|
||||||
# For more information about the robots.txt standard, see:
|
# For more information about the robots.txt standard, see:
|
||||||
# https://www.robotstxt.org/orig.html
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
@@ -62,12 +64,43 @@ class RobotsActionTest extends TestCase
|
|||||||
Allow: /baz
|
Allow: /baz
|
||||||
Disallow: /
|
Disallow: /
|
||||||
ROBOTS];
|
ROBOTS];
|
||||||
yield 'no short codes' => [[], <<<ROBOTS
|
yield 'no short codes' => [[], new RobotsOptions(), <<<ROBOTS
|
||||||
# For more information about the robots.txt standard, see:
|
# For more information about the robots.txt standard, see:
|
||||||
# https://www.robotstxt.org/orig.html
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /
|
Disallow: /
|
||||||
ROBOTS];
|
ROBOTS];
|
||||||
|
yield 'three short codes and allow all short urls' => [
|
||||||
|
['foo', 'bar', 'some'],
|
||||||
|
new RobotsOptions(allowAllShortUrls: true),
|
||||||
|
<<<ROBOTS
|
||||||
|
# For more information about the robots.txt standard, see:
|
||||||
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /rest/
|
||||||
|
ROBOTS,
|
||||||
|
];
|
||||||
|
yield 'no short codes and allow all short urls' => [[], new RobotsOptions(allowAllShortUrls: true), <<<ROBOTS
|
||||||
|
# For more information about the robots.txt standard, see:
|
||||||
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /rest/
|
||||||
|
ROBOTS];
|
||||||
|
yield 'allow user agents' => [[], new RobotsOptions(userAgents: ['foo', 'bar']), <<<ROBOTS
|
||||||
|
# For more information about the robots.txt standard, see:
|
||||||
|
# https://www.robotstxt.org/orig.html
|
||||||
|
|
||||||
|
User-agent: foo
|
||||||
|
User-agent: bar
|
||||||
|
Disallow: /
|
||||||
|
ROBOTS];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function action(RobotsOptions $options): RobotsAction
|
||||||
|
{
|
||||||
|
return new RobotsAction($this->helper, $options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
module/Core/test/Exception/InvalidIpFormatExceptionTest.php
Normal file
19
module/Core/test/Exception/InvalidIpFormatExceptionTest.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkioTest\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||||
|
|
||||||
|
class InvalidIpFormatExceptionTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function fromInvalidIp(): void
|
||||||
|
{
|
||||||
|
$e = InvalidIpFormatException::fromInvalidIp('invalid');
|
||||||
|
self::assertEquals('Provided IP invalid does not have the right format. Expected X.X.X.X', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequestFactory;
|
|||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestWith;
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||||
|
|
||||||
@@ -28,19 +29,19 @@ class RedirectConditionTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestWith([null, '', false])] // no accept language
|
#[TestWith([null, '', false], 'no accept language')]
|
||||||
#[TestWith(['', '', false])] // empty accept language
|
#[TestWith(['', '', false], 'empty accept language')]
|
||||||
#[TestWith(['*', '', false])] // wildcard accept language
|
#[TestWith(['*', '', false], 'wildcard accept language')]
|
||||||
#[TestWith(['en', 'en', true])] // single language match
|
#[TestWith(['en', 'en', true], 'single language match')]
|
||||||
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
|
#[TestWith(['es, en,fr', 'en', true], 'multiple languages match')]
|
||||||
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match
|
#[TestWith(['es, en-US,fr', 'EN', true], 'multiple locales match')]
|
||||||
#[TestWith(['es_ES', 'es-ES', true])] // single locale match
|
#[TestWith(['es_ES', 'es-ES', true], 'single locale match')]
|
||||||
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false])] // too low quality
|
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false], 'too low quality')]
|
||||||
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true])] // quality high enough
|
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true], 'quality high enough')]
|
||||||
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
|
#[TestWith(['en-UK', 'en-uk', true], 'different casing match')]
|
||||||
#[TestWith(['en-UK', 'en', true])] // only lang
|
#[TestWith(['en-UK', 'en', true], 'only lang')]
|
||||||
#[TestWith(['es-AR', 'en', false])] // different only lang
|
#[TestWith(['es-AR', 'en', false], 'different only lang')]
|
||||||
#[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale
|
#[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')]
|
||||||
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
|
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
|
||||||
{
|
{
|
||||||
$request = ServerRequestFactory::fromGlobals();
|
$request = ServerRequestFactory::fromGlobals();
|
||||||
@@ -72,4 +73,24 @@ class RedirectConditionTest extends TestCase
|
|||||||
|
|
||||||
self::assertEquals($expected, $result);
|
self::assertEquals($expected, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([null, '1.2.3.4', false], 'no remote IP address')]
|
||||||
|
#[TestWith(['1.2.3.4', '1.2.3.4', true], 'static IP address match')]
|
||||||
|
#[TestWith(['4.3.2.1', '1.2.3.4', false], 'no static IP address match')]
|
||||||
|
#[TestWith(['192.168.1.35', '192.168.1.0/24', true], 'CIDR block match')]
|
||||||
|
#[TestWith(['1.2.3.4', '192.168.1.0/24', false], 'no CIDR block match')]
|
||||||
|
#[TestWith(['192.168.1.35', '192.168.1.*', true], 'wildcard pattern match')]
|
||||||
|
#[TestWith(['1.2.3.4', '192.168.1.*', false], 'no wildcard pattern match')]
|
||||||
|
public function matchesRemoteIpAddress(?string $remoteIp, string $ipToMatch, bool $expected): void
|
||||||
|
{
|
||||||
|
$request = ServerRequestFactory::fromGlobals();
|
||||||
|
if ($remoteIp !== null) {
|
||||||
|
$request = $request->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, $remoteIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = RedirectCondition::forIpAddress($ipToMatch)->matchesRequest($request);
|
||||||
|
|
||||||
|
self::assertEquals($expected, $result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace RedirectRule\Entity;
|
namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity;
|
||||||
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Laminas\Diactoros\ServerRequestFactory;
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
class ShortUrlRedirectRuleTest extends TestCase
|
class ShortUrlRedirectRuleTest extends TestCase
|
||||||
{
|
{
|
||||||
#[Test, DataProvider('provideConditions')]
|
#[Test, DataProvider('provideConditions')]
|
||||||
@@ -55,9 +58,12 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||||||
#[Test, DataProvider('provideConditionMappingCallbacks')]
|
#[Test, DataProvider('provideConditionMappingCallbacks')]
|
||||||
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
|
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
|
||||||
{
|
{
|
||||||
$conditions = new ArrayCollection(
|
$conditions = new ArrayCollection([
|
||||||
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
|
RedirectCondition::forLanguage('en-UK'),
|
||||||
);
|
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||||
|
RedirectCondition::forDevice(DeviceType::ANDROID),
|
||||||
|
RedirectCondition::forIpAddress('1.2.3.*'),
|
||||||
|
]);
|
||||||
$rule = $this->createRule($conditions);
|
$rule = $this->createRule($conditions);
|
||||||
|
|
||||||
$result = $rule->mapConditions($callback);
|
$result = $rule->mapConditions($callback);
|
||||||
@@ -78,15 +84,27 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||||||
'matchKey' => 'foo',
|
'matchKey' => 'foo',
|
||||||
'matchValue' => 'bar',
|
'matchValue' => 'bar',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'type' => RedirectConditionType::DEVICE->value,
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => DeviceType::ANDROID->value,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => RedirectConditionType::IP_ADDRESS->value,
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => '1.2.3.*',
|
||||||
|
],
|
||||||
]];
|
]];
|
||||||
yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [
|
yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [
|
||||||
'en-UK language is accepted',
|
'en-UK language is accepted',
|
||||||
'query string contains foo=bar',
|
'query string contains foo=bar',
|
||||||
|
sprintf('device is %s', DeviceType::ANDROID->value),
|
||||||
|
'IP address matches 1.2.3.*',
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param ArrayCollection<RedirectCondition> $conditions
|
* @param ArrayCollection<int, RedirectCondition> $conditions
|
||||||
*/
|
*/
|
||||||
private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule
|
private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,9 +51,76 @@ class RedirectRulesDataTest extends TestCase
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]]])]
|
]]])]
|
||||||
|
#[TestWith([['redirectRules' => [
|
||||||
|
[
|
||||||
|
'longUrl' => 'https://example.com',
|
||||||
|
'conditions' => [
|
||||||
|
[
|
||||||
|
'type' => 'ip-address',
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => 'not an IP address',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]]])]
|
||||||
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
|
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
|
||||||
{
|
{
|
||||||
$this->expectException(ValidationException::class);
|
$this->expectException(ValidationException::class);
|
||||||
RedirectRulesData::fromRawData($invalidData);
|
RedirectRulesData::fromRawData($invalidData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([['redirectRules' => [
|
||||||
|
[
|
||||||
|
'longUrl' => 'https://example.com',
|
||||||
|
'conditions' => [
|
||||||
|
[
|
||||||
|
'type' => 'ip-address',
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => '1.2.3.4',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]]], 'static IP')]
|
||||||
|
#[TestWith([['redirectRules' => [
|
||||||
|
[
|
||||||
|
'longUrl' => 'https://example.com',
|
||||||
|
'conditions' => [
|
||||||
|
[
|
||||||
|
'type' => 'ip-address',
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => '1.2.3.0/24',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]]], 'CIDR block')]
|
||||||
|
#[TestWith([['redirectRules' => [
|
||||||
|
[
|
||||||
|
'longUrl' => 'https://example.com',
|
||||||
|
'conditions' => [
|
||||||
|
[
|
||||||
|
'type' => 'ip-address',
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => '1.2.3.*',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]]], 'IP wildcard pattern')]
|
||||||
|
#[TestWith([['redirectRules' => [
|
||||||
|
[
|
||||||
|
'longUrl' => 'https://example.com',
|
||||||
|
'conditions' => [
|
||||||
|
[
|
||||||
|
'type' => 'ip-address',
|
||||||
|
'matchKey' => null,
|
||||||
|
'matchValue' => '1.2.*.4',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]]], 'in-between IP wildcard pattern')]
|
||||||
|
public function allowsValidDataToBeSet(array $validData): void
|
||||||
|
{
|
||||||
|
$result = RedirectRulesData::fromRawData($validData);
|
||||||
|
self::assertEquals($result->rules, $validData['redirectRules']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||||
@@ -88,5 +89,30 @@ class ShortUrlRedirectionResolverTest extends TestCase
|
|||||||
RedirectCondition::forQueryParam('foo', 'bar'),
|
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||||
'https://example.com/from-rule',
|
'https://example.com/from-rule',
|
||||||
];
|
];
|
||||||
|
yield 'matching static IP address' => [
|
||||||
|
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.3.4'),
|
||||||
|
RedirectCondition::forIpAddress('1.2.3.4'),
|
||||||
|
'https://example.com/from-rule',
|
||||||
|
];
|
||||||
|
yield 'matching CIDR block' => [
|
||||||
|
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '192.168.1.35'),
|
||||||
|
RedirectCondition::forIpAddress('192.168.1.0/24'),
|
||||||
|
'https://example.com/from-rule',
|
||||||
|
];
|
||||||
|
yield 'matching wildcard IP address' => [
|
||||||
|
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.5.5'),
|
||||||
|
RedirectCondition::forIpAddress('1.2.*.*'),
|
||||||
|
'https://example.com/from-rule',
|
||||||
|
];
|
||||||
|
yield 'non-matching IP address' => [
|
||||||
|
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '4.3.2.1'),
|
||||||
|
RedirectCondition::forIpAddress('1.2.3.4'),
|
||||||
|
'https://example.com/foo/bar',
|
||||||
|
];
|
||||||
|
yield 'missing remote IP address' => [
|
||||||
|
$request(),
|
||||||
|
RedirectCondition::forIpAddress('1.2.3.4'),
|
||||||
|
'https://example.com/foo/bar',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
|
|||||||
self::assertEquals('Resolved "title"', $result->title);
|
self::assertEquals('Resolved "title"', $result->title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return InvocationMocker<ClientInterface>
|
||||||
|
*/
|
||||||
private function expectRequestToBeCalled(): InvocationMocker
|
private function expectRequestToBeCalled(): InvocationMocker
|
||||||
{
|
{
|
||||||
return $this->httpClient->expects($this->once())->method('request')->with(
|
return $this->httpClient->expects($this->once())->method('request')->with(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user