Compare commits

...

76 Commits

Author SHA1 Message Date
Alejandro Celaya
6b15cd6d51 Merge pull request #2204 from shlinkio/develop
Release 4.2.1
2024-10-04 12:53:11 +02:00
Alejandro Celaya
00169a5729 Require shlink-common 6.3 2024-10-04 12:48:19 +02:00
Alejandro Celaya
94702791d9 Merge pull request #2203 from acelaya-forks/feature/fix-memory-limit
Fix `MEMORY_LIMIT` being ignored when provided as installer config option
2024-10-04 12:43:38 +02:00
Alejandro Celaya
447cccacdf Update changelog 2024-10-04 12:41:02 +02:00
Alejandro Celaya
0413399102 Make sure MEMORY_LIMIT env var is read after config options have been promoted to env vars 2024-10-04 12:33:27 +02:00
Alejandro Celaya
9afc7876c4 Merge pull request #2184 from acelaya-forks/feature/redis-db-index
Allow specifying the redis database index to be used
2024-08-26 10:00:05 +02:00
Alejandro Celaya
187c17319a Take all Postgres platform classes into consideration 2024-08-26 09:57:17 +02:00
Alejandro Celaya
7310ecd886 Allow specifying the redis database index to be used 2024-08-25 12:51:34 +02:00
Alejandro Celaya
620cd92d11 Merge pull request #2172 from shlinkio/develop
Release v4.2.0
2024-08-11 18:33:09 +02:00
Alejandro Celaya
f9658c8da1 Add v4.2.0 to changelog 2024-08-11 18:30:06 +02:00
Alejandro Celaya
613b1d3045 Update changelog 2024-08-06 10:13:55 +02:00
Alejandro Celaya
d39711ec51 Merge pull request #2170 from acelaya-forks/feature/testdox-summary
Add --testdox-summary flag to phpunit executions
2024-08-04 13:16:32 +02:00
Alejandro Celaya
69dcab96f8 Add --testdox-summary flag to phpunit executions 2024-08-04 13:13:03 +02:00
Alejandro Celaya
d76c96ad41 Fix coding standard 2024-08-01 08:38:49 +02:00
Alejandro Celaya
133efff2cd Improve PHPStan config 2024-07-31 19:53:05 +02:00
Alejandro Celaya
c10f0db170 Merge pull request #2168 from acelaya-forks/feature/update-common
Update to latest shlink-common and remove deprecation references
2024-07-29 20:47:04 +02:00
Alejandro Celaya
037cd8a389 Add missing generic tyoes annotations 2024-07-29 20:43:52 +02:00
Alejandro Celaya
1d24750f43 Fix phpstan checks 2024-07-29 19:59:46 +02:00
Alejandro Celaya
b52ceaff9a Update to latest shlink-common and remove deprecation references 2024-07-29 19:41:40 +02:00
Alejandro Celaya
6b0b52853c Improve repro steps description in bug issue template 2024-07-28 10:49:24 +02:00
Alejandro Celaya
64d7ac7093 Merge pull request #2166 from acelaya-forks/feature/options-enum
Reduce hardcoded options in ShortUrlDataInput
2024-07-27 09:15:16 +02:00
Alejandro Celaya
b9ba1246d4 Reduce hardcoded options in ShortUrlDataInput 2024-07-27 09:12:54 +02:00
Alejandro Celaya
7f9dc10f6a Merge pull request #2164 from acelaya-forks/feature/update-url-cli
Add command to update short URLs
2024-07-26 20:14:02 +02:00
Alejandro Celaya
a1afc90150 Fix sqlcmd path 2024-07-26 20:09:59 +02:00
Alejandro Celaya
df94c68e2e Add unit test for EditShortUrlCommand 2024-07-26 19:54:39 +02:00
Alejandro Celaya
65ea1e00a6 Prevent resetting of non-providen params in EditShortUrlCommand 2024-07-26 19:26:48 +02:00
Alejandro Celaya
5bccdded8a Create command to edit existing short URLs 2024-07-26 09:21:00 +02:00
Alejandro Celaya
8917ed5c2e Create command to edit existing short URLs 2024-07-26 00:01:40 +02:00
Alejandro Celaya
fabc752398 Extract reading and parsing of arguments for short URLs data in commands 2024-07-25 23:44:46 +02:00
Alejandro Celaya
38d8086516 Merge pull request #2161 from acelaya-forks/feature/php-8.4-ci
Add PHP 8.4 to CI
2024-07-23 20:06:09 +02:00
Alejandro Celaya
ae0ff5f23c Add PHP 8.4 to CI 2024-07-23 20:02:49 +02:00
Alejandro Celaya
7c659699f3 Merge pull request #2151 from acelaya-forks/feature/ip-dynamic-redirects
Add logic for IP-based dynamic redirects
2024-07-18 21:32:24 +02:00
Alejandro Celaya
9e6cdcb838 Update changelog 2024-07-18 21:26:28 +02:00
Alejandro Celaya
7e2f755dfd Validate IP address patterns when creating ip-address redirect conditions 2024-07-18 21:23:48 +02:00
Alejandro Celaya
ce2ed237c7 Add ip-address condition type to redirect rules API spec docs 2024-07-17 20:23:58 +02:00
Alejandro Celaya
626caa4afa Add API test for dynamic IP-based redirects 2024-07-17 20:13:46 +02:00
Alejandro Celaya
f4a7712ded Add InvalidIpFormatExceptionTest 2024-07-17 19:59:13 +02:00
Alejandro Celaya
bab6a3951e Add missing unit test 2024-07-17 19:56:53 +02:00
Alejandro Celaya
f49d98f2ea Add logic for IP-based dynamic redirects 2024-07-17 19:51:13 +02:00
Alejandro Celaya
1312ea61f4 Add new IP address redirect condition 2024-07-06 10:35:33 +02:00
Alejandro Celaya
8d90661d0a Extract logic to match IP address against list of groups 2024-07-06 10:12:05 +02:00
Alejandro Celaya
b6b2530cb6 Merge pull request #2149 from acelaya-forks/feature/robots-user-agents
Add option to customize user agents in robots.txt
2024-07-06 10:08:03 +02:00
Alejandro Celaya
e4f66b7ce6 Update installer 2024-07-05 09:41:26 +02:00
Alejandro Celaya
4b52c92e97 Add option to customize user agents in robots.txt 2024-07-05 08:54:54 +02:00
Alejandro Celaya
76c42bc17c Merge pull request #2148 from acelaya-forks/feature/roadrunner-2024
Update to RoadRunner 2024
2024-07-03 19:56:36 +02:00
Alejandro Celaya
c4f8da5f02 Fix phpstan error definition 2024-07-03 19:53:26 +02:00
Alejandro Celaya
80bdeb280a Update to RoadRunner 2024 2024-07-03 19:52:06 +02:00
Alejandro Celaya
99010b6eae Fix merge conflicts 2024-05-23 09:26:27 +02:00
Alejandro Celaya
b2dabf06bf Merge pull request #2136 from acelaya-forks/release/4.1.1
Release/4.1.1
2024-05-23 09:21:56 +02:00
Alejandro Celaya
67ae05f473 Add v4.1.1 to changelog 2024-05-23 09:18:58 +02:00
Alejandro Celaya
fb4fecf411 Merge pull request #2135 from acelaya-forks/feature/non-utf8-titles
Convert encoding of resolved titles based on page encoding
2024-05-23 09:17:49 +02:00
Alejandro Celaya
c855f011d1 Merge pull request #2132 from acelaya-forks/feature/update-phpstan
Update to latest phpstan
2024-05-23 09:17:37 +02:00
Alejandro Celaya
02717eb2fb Merge pull request #2130 from marijnvandevoorde/nanoid
Replaces short-id by nano-id
2024-05-23 09:17:26 +02:00
Alejandro Celaya
de70ebe769 Merge pull request #2125 from acelaya-forks/feature/phpunit-11
Update to PHPUnit 11
2024-05-23 09:16:56 +02:00
Alejandro Celaya
c6109fd396 Merge pull request #2115 from acelaya-forks/feature/fix-oas-docs
Fix typo in OAS docs
2024-05-23 09:16:24 +02:00
Alejandro Celaya
83584a3175 Link crchived changelogs from main one 2024-05-23 09:15:40 +02:00
Alejandro Celaya
f5dcc52b3b Migrate to new docker-publish-image reusable workflow 2024-05-23 09:15:16 +02:00
Alejandro Celaya
1901964de1 Merge pull request #2135 from acelaya-forks/feature/non-utf8-titles
Convert encoding of resolved titles based on page encoding
2024-05-22 18:14:56 +02:00
Alejandro Celaya
80e9c2452b Convert encoding of resolved titles based on page encoding 2024-05-22 18:11:55 +02:00
Alejandro Celaya
5ad4b39160 Merge pull request #2132 from acelaya-forks/feature/update-phpstan
Update to latest phpstan
2024-05-21 19:05:39 +02:00
Alejandro Celaya
89b73a9cfa Update to latest phpstan 2024-05-21 18:09:45 +02:00
Alejandro Celaya
e2d8334d69 Merge pull request #2130 from marijnvandevoorde/nanoid
Replaces short-id by nano-id
2024-05-21 17:58:53 +02:00
Marijn Vandevoorde
9b16d7acc0 Replaces short-id by nano-id 2024-05-16 14:00:39 +02:00
Alejandro Celaya
6836840746 Merge pull request #2125 from acelaya-forks/feature/phpunit-11
Update to PHPUnit 11
2024-05-12 13:22:26 +02:00
Alejandro Celaya
4084d301ca Update to PHPUnit 11 2024-05-12 12:49:53 +02:00
Alejandro Celaya
added21b18 Merge pull request #2118 from shlinkio/revert-2117-feature/superfluous-distinct
Revert "Remove unneeded DISTINCT from list short URLs query"
2024-05-09 10:00:29 +02:00
Alejandro Celaya
8cd77391cc Revert "Remove unneeded DISTINCT from list short URLs query" 2024-05-09 09:43:55 +02:00
Alejandro Celaya
05ebfccc63 Merge pull request #2117 from acelaya-forks/feature/superfluous-distinct
Remove unneeded DISTINCT from list short URLs query
2024-05-06 18:54:01 +02:00
Alejandro Celaya
cb3a690294 Remove unneeded DISTINCT from list short URLs query 2024-05-06 18:50:10 +02:00
Alejandro Celaya
194a7b0e57 Merge pull request #2115 from acelaya-forks/feature/fix-oas-docs
Fix typo in OAS docs
2024-04-29 15:22:32 +02:00
Alejandro Celaya
98e4d01feb Fix typo in OAS docs 2024-04-29 15:18:54 +02:00
Alejandro Celaya
c22e3895b5 Allow more dev hosts in dev mercure 2024-04-29 08:52:18 +02:00
Alejandro Celaya
9a76c19615 Migrate to new docker-publish-image reusable workflow 2024-04-26 09:27:21 +02:00
Alejandro Celaya
59fa088975 Merge pull request #2107 from acelaya-forks/feature/robots-allow-all
Add option to allow all URLs to be crawlable via robots.txt
2024-04-22 09:23:34 +02:00
Alejandro Celaya
163244f40f Add option to allow all URLs to be crawlable via robots.txt 2024-04-22 09:16:44 +02:00
Alejandro Celaya
a89b53af4f Link crchived changelogs from main one 2024-04-21 16:46:24 +02:00
131 changed files with 1374 additions and 447 deletions

View File

@@ -61,7 +61,11 @@ body:
label: Minimum steps to reproduce
value: |
<!--
Emphasis in MINIMUM: What is the simplest way to reproduce the bug?
Avoid things like "Create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause and the bug may be closed as "not reproducible".
If you can provide a simple docker compose config, that's even better.
Simple but detailed way to reproduce the bug:
* Avoid things like "create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause.
* Avoid too vague steps or one-liners like "Update from v1 to v2".
* Providing the reproduction in the form of a self-contained docker-composer is desirable.
Failing in any of these will cause the issue to be closed as "not reproducible".
-->

View File

@@ -44,5 +44,5 @@ runs:
ini-values: pcov.directory=module
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }}
shell: bash

View File

@@ -10,10 +10,11 @@ on:
jobs:
db-tests:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3']
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '8.4' }}
env:
LC_ALL: C
steps:
@@ -31,12 +32,12 @@ jobs:
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
if: ${{ inputs.platform == 'ms' }}
run: docker compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
run: docker compose exec -T shlink_db_ms /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- name: Run tests
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |

View File

@@ -7,7 +7,7 @@ on:
jobs:
build-docker-image:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -10,10 +10,11 @@ on:
jobs:
tests:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3']
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '8.4' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
@@ -33,7 +34,7 @@ jobs:
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' }}
if: ${{ matrix.php-version == '8.3' }}
with:
name: coverage-${{ inputs.test-group }}
path: |

View File

@@ -24,10 +24,10 @@ on:
jobs:
static-analysis:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']
php-version: ['8.3']
command: ['cs', 'stan', 'swagger:validate']
steps:
- uses: actions/checkout@v4
@@ -66,10 +66,10 @@ jobs:
- api-tests
- cli-tests
- db-tests
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']
php-version: ['8.3']
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -94,7 +94,7 @@ jobs:
delete-artifacts:
needs:
- upload-coverage
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
with:

View File

@@ -15,7 +15,7 @@ jobs:
- runtime: 'rr'
tag-suffix: 'roadrunner'
platforms: 'linux/arm64/v8,linux/amd64'
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink

View File

@@ -7,10 +7,10 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3']
php-version: ['8.2', '8.3'] # TODO 8.4
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
@@ -26,7 +26,7 @@ jobs:
publish:
needs: ['build']
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
@@ -43,7 +43,7 @@ jobs:
delete-artifacts:
needs: ['publish']
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
with:

View File

@@ -7,7 +7,7 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']

View File

@@ -4,6 +4,70 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [4.2.1] - 2024-10-04
### Added
* [#2183](https://github.com/shlinkio/shlink/issues/2183) Redis database index to be used can now be specified in the connection URI path, and Shlink will honor it.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2201](https://github.com/shlinkio/shlink/issues/2201) Fix `MEMORY_LIMIT` option being ignored when provided via installer options.
## [4.2.0] - 2024-08-11
### Added
* [#2120](https://github.com/shlinkio/shlink/issues/2120) Add new IP address condition for the dynamic rules redirections system.
The conditions allow you to define IP addresses to match as static IP (1.2.3.4), CIDR block (192.168.1.0/24) or wildcard pattern (1.2.\*.\*).
* [#2018](https://github.com/shlinkio/shlink/issues/2018) Add option to allow all short URLs to be unconditionally crawlable in robots.txt, via `ROBOTS_ALLOW_ALL_SHORT_URLS=true` env var, or config option.
* [#2109](https://github.com/shlinkio/shlink/issues/2109) Add option to customize user agents robots.txt, via `ROBOTS_USER_AGENTS=foo,bar,baz` env var, or config option.
* [#2163](https://github.com/shlinkio/shlink/issues/2163) Add `short-urls:edit` command to edit existing short URLs.
This brings CLI and API interfaces capabilities closer, and solves an overlook since the feature was implemented years ago.
* [#2164](https://github.com/shlinkio/shlink/pull/2164) Add missing `--title` option to `short-url:create` and `short-url:edit` commands.
### Changed
* [#2096](https://github.com/shlinkio/shlink/issues/2096) Update to RoadRunner 2024.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.1.1] - 2024-05-23
### Added
* *Nothing*
### Changed
* Use new reusable workflow to publish docker image
* [#2015](https://github.com/shlinkio/shlink/issues/2015) Update to PHPUnit 11.
* [#2130](https://github.com/shlinkio/shlink/pull/2130) Replace deprecated `pugx/shortid-php` package with `hidehalo/nanoid-php`.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2111](https://github.com/shlinkio/shlink/issues/2111) Fix typo in OAS docs examples where redirect rules with `query-param` condition type were defined as `query`.
* [#2129](https://github.com/shlinkio/shlink/issues/2129) Fix error when resolving title for sites not using UTF-8 charset (detected with Japanese charsets).
## [4.1.0] - 2024-04-14
### Added
* [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit.
@@ -824,3 +888,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
### Fixed
* *Nothing*
## Older versions
* [2.x.x](docs/changelog-archive/CHANGELOG-2.x.md)
* [1.x.x](docs/changelog-archive/CHANGELOG-1.x.md)

View File

@@ -22,7 +22,7 @@ echo 'Starting server...'
-o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
sleep 2 # Let's give the server a couple of seconds to start
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --testdox-summary $*
TESTS_EXIT_CODE=$?
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w .

View File

@@ -16,19 +16,21 @@
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^3.0.2",
"doctrine/dbal": "^4.0",
"doctrine/dbal": "^4.1",
"doctrine/migrations": "^3.6",
"doctrine/orm": "^3.0",
"doctrine/orm": "^3.2",
"endroid/qr-code": "^5.0",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.5",
"hidehalo/nanoid-php": "^1.1",
"jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13",
"laminas/laminas-config-aggregator": "^1.15",
"laminas/laminas-diactoros": "^3.3",
"laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21",
@@ -40,20 +42,19 @@
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^4.8",
"pagerfanta/core": "^3.8",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "^6.1",
"shlinkio/shlink-common": "^6.3",
"shlinkio/shlink-config": "^3.0",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.3.2",
"shlinkio/shlink-installer": "^9.1",
"shlinkio/shlink-installer": "^9.2",
"shlinkio/shlink-ip-geolocation": "^4.0",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.3",
"spiral/roadrunner": "^2024.1",
"spiral/roadrunner-cli": "^2.6",
"spiral/roadrunner-http": "^3.3",
"spiral/roadrunner-jobs": "^4.3",
"spiral/roadrunner-http": "^3.5",
"spiral/roadrunner-jobs": "^4.5",
"symfony/console": "^7.0",
"symfony/filesystem": "^7.0",
"symfony/lock": "^7.0",
@@ -63,13 +64,13 @@
"require-dev": {
"devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/php-code-coverage": "^10.1",
"phpunit/phpcov": "^9.0",
"phpunit/phpunit": "^10.4",
"phpstan/phpstan": "^1.11",
"phpstan/phpstan-doctrine": "^1.4",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-symfony": "^1.4",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.3",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1",
@@ -113,16 +114,16 @@
],
"cs": "phpcs -s",
"cs:fix": "phpcbf",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config module/*/migrations config docker/config --level=8",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse",
"test": [
"@parallel test:unit test:db",
"@parallel test:api test:cli"
],
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --testdox --testdox-summary",
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov",
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite -- $*",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite -- $*",
@@ -135,7 +136,7 @@
"test:api:mssql": "DB_DRIVER=mssql composer test:api -- $*",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov",
"test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov",
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml",
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-cli.xml",
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov",
"test:cli:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov",
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",

View File

@@ -45,6 +45,8 @@ return [
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
Option\UrlShortener\ShortUrlModeConfigOption::class,
Option\Robots\RobotsAllowAllShortUrlsConfigOption::class,
Option\Robots\RobotsUserAgentsConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,

View 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()),
],
];

View File

@@ -4,40 +4,35 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
/** @var string|null $disableTrackingFrom */
$disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv();
use function Shlinkio\Shlink\Core\splitByComma;
return [
return [
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
// 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),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => $disableTrackingFrom === null
? []
: array_map(trim(...), explode(',', $disableTrackingFrom)),
],
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => splitByComma(EnvVars::DISABLE_TRACKING_FROM->loadFromEnv()),
],
];
})();
];

View File

@@ -12,7 +12,6 @@ const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0;

View File

@@ -12,8 +12,6 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// Set a default memory limit, but allow custom values
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv('512M'));
// This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
@@ -25,6 +23,10 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) {
return (static function (): ServiceManager {
$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->setService('config', $config);

View File

@@ -1,5 +1,3 @@
version: '3'
services:
shlink_db_mysql:
environment:

View File

@@ -1,5 +1,3 @@
version: '3'
services:
shlink_php:
user: 1000:1000

View File

@@ -1,5 +1,3 @@
version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
@@ -79,7 +77,7 @@ services:
shlink_db_postgres:
container_name: shlink_db_postgres
image: postgres:12.2-alpine
image: postgres:16.3-alpine
ports:
- "5434:5432"
volumes:
@@ -105,7 +103,7 @@ services:
shlink_db_ms:
container_name: shlink_db_ms
image: mcr.microsoft.com/mssql/server:2019-latest
image: mcr.microsoft.com/mssql/server:2022-latest
ports:
- "1433:1433"
environment:
@@ -147,7 +145,7 @@ services:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000 http://localhost:3002 http://127.0.0.1:3002 http://localhost:3005 http://127.0.0.1:3005"
shlink_rabbitmq:
container_name: shlink_rabbitmq

View File

@@ -15,8 +15,8 @@
"properties": {
"type": {
"type": "string",
"enum": ["device", "language", "query-param"],
"description": "The type of the condition, which will condition the logic used to match it"
"enum": ["device", "language", "query-param", "ip-address"],
"description": "The type of the condition, which will determine the logic used to match it"
},
"matchKey": {
"type": ["string", "null"]

View File

@@ -77,12 +77,12 @@
"priority": 3,
"conditions": [
{
"type": "query",
"type": "query-param",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"type": "query-param",
"matchKey": "hello",
"matchValue": "world"
}
@@ -209,12 +209,12 @@
"longUrl": "https://example.com/query-foo-bar-hello-world",
"conditions": [
{
"type": "query",
"type": "query-param",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"type": "query-param",
"matchKey": "hello",
"matchValue": "world"
}
@@ -280,12 +280,12 @@
"priority": 3,
"conditions": [
{
"type": "query",
"type": "query-param",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"type": "query-param",
"matchKey": "hello",
"matchValue": "world"
}

View File

@@ -9,6 +9,7 @@ return [
'cli' => [
'commands' => [
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
Command\ShortUrl\EditShortUrlCommand::NAME => Command\ShortUrl\EditShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,

View File

@@ -41,6 +41,7 @@ return [
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\EditShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
@@ -92,6 +93,7 @@ return [
ShortUrlStringifier::class,
UrlShortenerOptions::class,
],
Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class, ShortUrlStringifier::class],
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
ShortUrl\ShortUrlListService::class,

View File

@@ -33,6 +33,9 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$domain = $input->getArgument('domain');

View File

@@ -4,24 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
use function array_unique;
use function explode;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function sprintf;
class CreateShortUrlCommand extends Command
@@ -29,6 +23,7 @@ class CreateShortUrlCommand extends Command
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
private readonly ShortUrlDataInput $shortUrlDataInput;
public function __construct(
private readonly UrlShortenerInterface $urlShortener,
@@ -36,6 +31,7 @@ class CreateShortUrlCommand extends Command
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this);
}
protected function configure(): void
@@ -43,26 +39,11 @@ class CreateShortUrlCommand extends Command
$this
->setName(self::NAME)
->setDescription('Generates a short URL for provided long URL and returns it')
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the new short URL',
)
->addOption(
'valid-since',
's',
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
'valid-until',
'u',
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
'The domain to which this short URL will be attached.',
)
->addOption(
'custom-slug',
@@ -70,30 +51,6 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'max-visits',
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
->addOption(
'short-code-length',
'l',
@@ -101,16 +58,16 @@ class CreateShortUrlCommand extends Command
'The length for generated short code (it will be ignored if --custom-slug was provided).',
)
->addOption(
'crawlable',
'r',
InputOption::VALUE_NONE,
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'no-forward-query',
'w',
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
'This will force existing matching URL to be returned if found, instead of creating a new one.',
);
}
@@ -136,32 +93,17 @@ class CreateShortUrlCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = $this->getIO($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error('A URL was not provided!');
return ExitCode::EXIT_FAILURE;
}
$explodeWithComma = static fn (string $tag) => explode(',', $tag);
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
try {
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
], $this->options));
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
$input,
$this->options,
customSlugField: 'custom-slug',
shortCodeLengthField: 'short-code-length',
pathPrefixField: 'path-prefix',
findIfExistsField: 'find-if-exists',
domainField: 'domain',
));
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the '
@@ -169,7 +111,7 @@ class CreateShortUrlCommand extends Command
));
$io->writeln([
sprintf('Processed long URL: <info>%s</info>', $longUrl),
sprintf('Processed long URL: <info>%s</info>', $result->shortUrl->getLongUrl()),
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
]);
return ExitCode::EXIT_SUCCESS;
@@ -181,6 +123,6 @@ class CreateShortUrlCommand extends Command
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
{
return $this->io ?? ($this->io = new SymfonyStyle($input, $output));
return $this->io ??= new SymfonyStyle($input, $output);
}
}

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

View File

@@ -46,6 +46,9 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
}
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);

View File

@@ -9,14 +9,14 @@ use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -32,8 +32,6 @@ use function sprintf;
class ListShortUrlsCommand extends Command
{
use PagerfantaUtilsTrait;
public const NAME = 'short-url:list';
private readonly StartDateOption $startDateOption;
@@ -41,7 +39,7 @@ class ListShortUrlsCommand extends Command
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
private readonly DataTransformerInterface $transformer,
private readonly ShortUrlDataTransformerInterface $transformer,
) {
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'short URLs');
@@ -179,6 +177,7 @@ class ListShortUrlsCommand extends Command
/**
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
* @return Paginator<ShortUrlWithVisitsSummary>
*/
private function renderPage(
OutputInterface $output,
@@ -196,7 +195,7 @@ class ListShortUrlsCommand extends Command
ShlinkTable::default($output)->render(
array_keys($columnsMap),
$rows,
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
$all ? null : PagerfantaUtils::formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
);
return $shortUrls;

View File

@@ -33,6 +33,9 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');

View File

@@ -46,6 +46,9 @@ abstract class AbstractVisitsListCommand extends Command
return ExitCode::EXIT_SUCCESS;
}
/**
* @param Paginator<Visit> $paginator
*/
private function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = [];
@@ -74,6 +77,9 @@ abstract class AbstractVisitsListCommand extends Command
];
}
/**
* @return Paginator<Visit>
*/
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**

View File

@@ -30,6 +30,9 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
->setDescription('Returns the list of non-orphan visits.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));

View File

@@ -30,6 +30,9 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
));
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$rawType = $input->getOption('type');

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

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

View File

@@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
$this->askMandatory('Query param name?', $io),
$this->askOptional('Query param value?', $io),
),
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),
};
$continue = $io->confirm('Do you want to add another condition?');

View File

@@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -31,6 +32,7 @@ class CreateDatabaseCommandTest extends TestCase
private MockObject & ProcessRunnerInterface $processHelper;
private MockObject & Connection $regularConn;
private MockObject & ClassMetadataFactory $metadataFactory;
/** @var MockObject&AbstractSchemaManager<SQLitePlatform> */
private MockObject & AbstractSchemaManager $schemaManager;
private MockObject & Driver $driver;

View File

@@ -34,8 +34,8 @@ class MatomoSendVisitsCommandTest extends TestCase
}
#[Test]
#[TestWith([true])]
#[TestWith([false])]
#[TestWith([true], 'interactive')]
#[TestWith([false], 'not interactive')]
public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());

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

View File

@@ -116,6 +116,7 @@ class RedirectRuleHandlerTest extends TestCase
'Language to match?' => 'en-US',
'Query param name?' => 'foo',
'Query param value?' => 'bar',
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
default => '',
},
);
@@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
true,
];
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
}
#[Test]

View File

@@ -25,6 +25,7 @@ class CliTestUtils
$command = $generator->testDouble(
Command::class,
mockObject: true,
markAsMockObject: true,
callOriginalConstructor: false,
callOriginalClone: false,
cloneArguments: false,

View File

@@ -32,6 +32,7 @@ return [
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
Options\RobotsOptions::class => [ValinorConfigFactory::class, 'config.robots'],
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
@@ -189,7 +190,7 @@ return [
'Logger_Shlink',
Options\QrCodeOptions::class,
],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Options\RobotsOptions::class],
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
'em',

View File

@@ -9,11 +9,13 @@ use Cake\Chronos\Chronos;
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use GuzzleHttp\Psr7\Query;
use Hidehalo\Nanoid\Client as NanoidClient;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
@@ -37,15 +39,15 @@ use function ucfirst;
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
{
static $shortIdFactory;
if ($shortIdFactory === null) {
$shortIdFactory = new ShortIdFactory();
static $nanoIdClient;
if ($nanoIdClient === null) {
$nanoIdClient = new NanoidClient();
}
$alphabet = $mode === ShortUrlMode::STRICT
? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
: '0123456789abcdefghijklmnopqrstuvwxyz';
return $shortIdFactory->generate($length, $alphabet)->serialize();
return $nanoIdClient->formattedId($alphabet, $length);
}
function parseDateFromQuery(array $query, string $dateName): ?Chronos
@@ -107,7 +109,6 @@ function normalizeLocale(string $locale): string
* minimum quality
*
* @param non-empty-string $acceptLanguage
* @param float<0, 1> $minQuality
* @return iterable<string>;
*/
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
@@ -140,21 +141,31 @@ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0):
*/
function splitLocale(string $locale): array
{
return array_pad(explode('-', $locale), length: 2, value: null);
[$lang, $countryCode] = array_pad(explode('-', $locale), length: 2, value: null);
return [$lang, $countryCode];
}
/**
* @param InputFilter<mixed> $inputFilter
*/
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
}
/**
* @param InputFilter<mixed> $inputFilter
*/
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (bool) $value : null;
}
/**
* @param InputFilter<mixed> $inputFilter
*/
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
{
$value = $inputFilter->getValue($fieldName);
@@ -260,3 +271,21 @@ function enumToString(string $enum): string
{
return sprintf('["%s"]', implode('", "', enumValues($enum)));
}
/**
* Split provided string by comma and return a list of the results.
* An empty array is returned if provided value is empty
*/
function splitByComma(?string $value): array
{
if ($value === null || trim($value) === '') {
return [];
}
return array_map(trim(...), explode(',', $value));
}
function ipAddressFromRequest(ServerRequestInterface $request): ?string
{
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
}

View File

@@ -10,14 +10,15 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
use Shlinkio\Shlink\Core\Options\RobotsOptions;
use function sprintf;
use const PHP_EOL;
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
readonly class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
{
public function __construct(private readonly CrawlingHelperInterface $crawlingHelper)
public function __construct(private CrawlingHelperInterface $crawlingHelper, private RobotsOptions $robotsOptions)
{
}
@@ -33,10 +34,20 @@ class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
# For more information about the robots.txt standard, see:
# https://www.robotstxt.org/orig.html
User-agent: *
ROBOTS;
$userAgents = $this->robotsOptions->hasUserAgents() ? $this->robotsOptions->userAgents : ['*'];
foreach ($userAgents as $userAgent) {
yield sprintf('User-agent: %s%s', $userAgent, PHP_EOL);
}
if ($this->robotsOptions->allowAllShortUrls) {
// Disallow rest URLs, but allow all short codes
yield 'Disallow: /rest/';
return;
}
$shortCodes = $this->crawlingHelper->listCrawlableShortCodes();
foreach ($shortCodes as $shortCode) {
yield sprintf('Allow: /%s%s', $shortCode, PHP_EOL);

View File

@@ -69,8 +69,10 @@ enum EnvVars: string
case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
case TIMEZONE = 'TIMEZONE';
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
case ROBOTS_ALLOW_ALL_SHORT_URLS = 'ROBOTS_ALLOW_ALL_SHORT_URLS';
case ROBOTS_USER_AGENTS = 'ROBOTS_USER_AGENTS';
case TIMEZONE = 'TIMEZONE';
case MEMORY_LIMIT = 'MEMORY_LIMIT';
public function loadFromEnv(mixed $default = null): mixed

View File

@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends EntitySpecificationRepository<Domain> */
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
{
/**

View File

@@ -9,6 +9,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterfa
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends ObjectRepository<Domain> */
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
/**

View File

@@ -8,6 +8,7 @@ use Laminas\InputFilter\InputFilter;
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
/** @extends InputFilter<mixed> */
class DomainRedirectsInputFilter extends InputFilter
{
public const DOMAIN = 'domain';

View File

@@ -4,21 +4,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface
{
public function __construct(private DataTransformerInterface $shortUrlTransformer)
public function __construct(private ShortUrlDataTransformerInterface $shortUrlTransformer)
{
}
public function newVisitUpdate(Visit $visit): Update
{
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
'shortUrl' => $this->shortUrlTransformer->transform($visit->shortUrl),
'shortUrl' => $this->transformShortUrl($visit->shortUrl),
'visit' => $visit->jsonSerialize(),
]);
}
@@ -36,7 +36,7 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
$topic = Topic::newShortUrlVisit($shortUrl?->getShortCode());
return Update::forTopicAndPayload($topic, [
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
'shortUrl' => $this->transformShortUrl($shortUrl),
'visit' => $visit->jsonSerialize(),
]);
}
@@ -47,4 +47,9 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
]);
}
private function transformShortUrl(?ShortUrl $shortUrl): array
{
return $shortUrl === null ? [] : $this->shortUrlTransformer->transform($shortUrl);
}
}

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

View File

@@ -26,6 +26,9 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
private array $invalidElements;
/**
* @param InputFilterInterface<mixed> $inputFilter
*/
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
{
return static::fromArray($inputFilter->getMessages(), $prev);

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

View File

@@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Pagerfanta\Adapter\AdapterInterface;
/**
* @template T
* @implements AdapterInterface<T>
*/
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
{
private ?int $count = null;

View File

@@ -8,9 +8,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
use function Shlinkio\Shlink\Core\normalizeLocale;
use function Shlinkio\Shlink\Core\splitLocale;
use function sprintf;
@@ -41,6 +43,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return new self(RedirectConditionType::DEVICE, $device->value);
}
/**
* @param string $ipAddressPattern - A static IP address (100.200.80.40), CIDR block (192.168.10.0/24) or wildcard
* pattern (11.22.*.*)
*/
public static function forIpAddress(string $ipAddressPattern): self
{
return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern);
}
public static function fromRawData(array $rawData): self
{
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
@@ -59,6 +70,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
RedirectConditionType::DEVICE => $this->matchesDevice($request),
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
};
}
@@ -100,6 +112,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return $device !== null && $device->value === strtolower($this->matchValue);
}
private function matchesRemoteIpAddress(ServerRequestInterface $request): bool
{
$remoteAddress = ipAddressFromRequest($request);
return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]);
}
public function jsonSerialize(): array
{
return [
@@ -119,6 +137,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
$this->matchKey,
$this->matchValue,
),
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
};
}
}

View File

@@ -7,4 +7,5 @@ enum RedirectConditionType: string
case DEVICE = 'device';
case LANGUAGE = 'language';
case QUERY_PARAM = 'query-param';
case IP_ADDRESS = 'ip-address';
}

View File

@@ -12,10 +12,12 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
/** @extends InputFilter<mixed> */
class RedirectRulesInputFilter extends InputFilter
{
public const REDIRECT_RULES = 'redirectRules';
@@ -43,6 +45,9 @@ class RedirectRulesInputFilter extends InputFilter
return $instance;
}
/**
* @return InputFilter<mixed>
*/
private static function createRedirectRuleInputFilter(): InputFilter
{
$redirectRuleInputFilter = new InputFilter();
@@ -59,6 +64,9 @@ class RedirectRulesInputFilter extends InputFilter
return $redirectRuleInputFilter;
}
/**
* @return InputFilter<mixed>
*/
private static function createRedirectConditionInputFilter(): InputFilter
{
$redirectConditionInputFilter = new InputFilter();
@@ -71,13 +79,14 @@ class RedirectRulesInputFilter extends InputFilter
$redirectConditionInputFilter->add($type);
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
return contains($value, enumValues(DeviceType::class));
}
return true;
}));
$value->getValidatorChain()->attach(new Callback(
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
// RedirectConditionType::LANGUAGE->value => TODO,
default => true,
},
));
$redirectConditionInputFilter->add($value);
$redirectConditionInputFilter->add(

View File

@@ -37,8 +37,8 @@ class ShortUrl extends AbstractEntity
{
/**
* @param Collection<int, Tag> $tags
* @param Collection<int, Visit> & Selectable $visits
* @param Collection<int, ShortUrlVisitsCount> & Selectable $visitsCounts
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
* @param Collection<int, ShortUrlVisitsCount> & Selectable<int, ShortUrlVisitsCount> $visitsCounts
*/
private function __construct(
private string $longUrl,
@@ -213,7 +213,7 @@ class ShortUrl extends AbstractEntity
}
/**
* @param Collection<int, Visit> & Selectable $visits
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
* @internal
*/
public function setVisits(Collection & Selectable $visits): self

View File

@@ -12,20 +12,24 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Throwable;
use function html_entity_decode;
use function mb_convert_encoding;
use function preg_match;
use function str_contains;
use function str_starts_with;
use function strtolower;
use function trim;
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{
public const MAX_REDIRECTS = 15;
public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/121.0.0.0 Safari/537.36';
// Matches the value inside a html title tag
private const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i';
// Matches the charset inside a Content-Type header
private const CHARSET_VALUE = '/charset=([^;]+)/i';
public function __construct(
private ClientInterface $httpClient,
private UrlShortenerOptions $options,
@@ -53,7 +57,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
return $data;
}
$title = $this->tryToResolveTitle($response);
$title = $this->tryToResolveTitle($response, $contentType);
return $title !== null ? $data->withResolvedTitle($title) : $data;
}
@@ -76,7 +80,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
}
}
private function tryToResolveTitle(ResponseInterface $response): ?string
private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?string
{
$collectedBody = '';
$body = $response->getBody();
@@ -84,12 +88,19 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
$collectedBody .= $body->read(1024);
}
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
}
private function normalizeTitle(string $title): string
{
// Try to match the title from the <title /> tag
preg_match(self::TITLE_TAG_VALUE, $collectedBody, $titleMatches);
if (! isset($titleMatches[1])) {
return null;
}
// Get the page's charset from Content-Type header
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
$title = isset($charsetMatches[1])
? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1])
: $titleMatches[1];
return html_entity_decode(trim($title));
}
}

View File

@@ -20,6 +20,7 @@ use function substr;
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
/** @extends InputFilter<mixed> */
class ShortUrlInputFilter extends InputFilter
{
// Fields for creation only

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use function Shlinkio\Shlink\Core\enumValues;
/** @extends InputFilter<mixed> */
class ShortUrlsParamsInputFilter extends InputFilter
{
public const PAGE = 'page';

View File

@@ -6,11 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @implements AdapterInterface<ShortUrlWithVisitsSummary> */
readonly class ShortUrlRepositoryAdapter implements AdapterInterface
{
public function __construct(

View File

@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
/** @extends EntitySpecificationRepository<ShortUrl> */
class CrawlableShortCodesQuery extends EntitySpecificationRepository implements CrawlableShortCodesQueryInterface
{
/**

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use function sprintf;
/** @extends EntitySpecificationRepository<ShortUrl> */
class ExpiredShortUrlsRepository extends EntitySpecificationRepository implements ExpiredShortUrlsRepositoryInterface
{
/**

View File

@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
/** @extends EntitySpecificationRepository<ShortUrl> */
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
{
/**

View File

@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function count;
use function strtolower;
/** @extends EntitySpecificationRepository<ShortUrl> */
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
{
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
/** @extends ObjectRepository<ShortUrl> */
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl;

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -21,7 +20,7 @@ readonly class ShortUrlListService implements ShortUrlListServiceInterface
}
/**
* @return ShortUrlWithVisitsSummary[]|Paginator
* @inheritDoc
*/
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
{

View File

@@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlListServiceInterface
{
/**
* @return ShortUrlWithVisitsSummary[]|Paginator
* @return Paginator<ShortUrlWithVisitsSummary>
*/
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
}

View File

@@ -4,24 +4,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
/**
* @fixme Do not implement DataTransformerInterface, but a separate interface
*/
readonly class ShortUrlDataTransformer implements DataTransformerInterface
readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface
{
public function __construct(private ShortUrlStringifierInterface $stringifier)
{
}
/**
* @param ShortUrlWithVisitsSummary|ShortUrl $data
*/
public function transform($data): array // phpcs:ignore
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array
{
$shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data;
return [

View File

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

View File

@@ -7,9 +7,11 @@ namespace Shlinkio\Shlink\Core\Tag\Entity;
use Doctrine\Common\Collections;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
class Tag extends AbstractEntity implements JsonSerializable
{
/** @var Collections\Collection<int, ShortUrl> */
private Collections\Collection $shortUrls;
public function __construct(private string $name)

View File

@@ -12,6 +12,10 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/**
* @template T
* @implements AdapterInterface<T>
*/
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
{
public function __construct(

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
/** @extends AbstractTagsPaginatorAdapter<TagInfo> */
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
{
public function getSlice(int $offset, int $length): iterable

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
/** @extends AbstractTagsPaginatorAdapter<Tag> */
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
{
public function getSlice(int $offset, int $length): iterable

View File

@@ -23,6 +23,7 @@ use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
use const PHP_INT_MAX;
/** @extends EntitySpecificationRepository<Tag> */
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
{
public function deleteByName(array $names): int

View File

@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\Core\Tag\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends ObjectRepository<Tag> */
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public function deleteByName(array $names): int;

View File

@@ -11,7 +11,6 @@ use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
@@ -20,14 +19,14 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class TagService implements TagServiceInterface
readonly class TagService implements TagServiceInterface
{
public function __construct(private readonly ORM\EntityManagerInterface $em)
public function __construct(private ORM\EntityManagerInterface $em)
{
}
/**
* @return Tag[]|Paginator
* @inheritDoc
*/
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator
{
@@ -37,7 +36,7 @@ class TagService implements TagServiceInterface
}
/**
* @return TagInfo[]|Paginator
* @inheritDoc
*/
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator
{
@@ -46,6 +45,11 @@ class TagService implements TagServiceInterface
return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params);
}
/**
* @template T
* @param AdapterInterface<T> $adapter
* @return Paginator<T>
*/
private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
{
return (new Paginator($adapter))
@@ -54,8 +58,7 @@ class TagService implements TagServiceInterface
}
/**
* @param string[] $tagNames
* @throws ForbiddenTagOperationException
* @inheritDoc
*/
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
{
@@ -69,9 +72,7 @@ class TagService implements TagServiceInterface
}
/**
* @throws TagNotFoundException
* @throws TagConflictException
* @throws ForbiddenTagOperationException
* @inheritDoc
*/
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
{

View File

@@ -17,12 +17,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagServiceInterface
{
/**
* @return Tag[]|Paginator
* @return Paginator<Tag>
*/
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @return TagInfo[]|Paginator
* @return Paginator<TagInfo>
*/
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;

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

View File

@@ -58,9 +58,10 @@ final class OrphanVisitsCountTracker
$conn = $em->getConnection();
$platformClass = $conn->getDatabasePlatform();
match ($platformClass::class) {
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $isBot),
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $isBot),
match (true) {
$platformClass instanceof PostgreSQLPlatform => $this->incrementForPostgres($conn, $isBot),
$platformClass instanceof SQLitePlatform || $platformClass instanceof SQLServerPlatform
=> $this->incrementForOthers($conn, $isBot),
default => $this->incrementForMySQL($conn, $isBot),
};
}

View File

@@ -64,9 +64,10 @@ final class ShortUrlVisitsCountTracker
$conn = $em->getConnection();
$platformClass = $conn->getDatabasePlatform();
match ($platformClass::class) {
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $shortUrlId, $isBot),
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $shortUrlId, $isBot),
match (true) {
$platformClass instanceof PostgreSQLPlatform => $this->incrementForPostgres($conn, $shortUrlId, $isBot),
$platformClass instanceof SQLitePlatform || $platformClass instanceof SQLServerPlatform
=> $this->incrementForOthers($conn, $shortUrlId, $isBot),
default => $this->incrementForMySQL($conn, $shortUrlId, $isBot),
};
}

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
use function Shlinkio\Shlink\Core\isCrawler;
use function substr;
@@ -46,7 +46,7 @@ final class Visitor
return new self(
$request->getHeaderLine('User-Agent'),
$request->getHeaderLine('Referer'),
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
ipAddressFromRequest($request),
$request->getUri()->__toString(),
);
}

View File

@@ -5,19 +5,23 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/**
* @extends AbstractCacheableCountPaginatorAdapter<Visit>
*/
class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $visitRepository,
private string $domain,
private VisitsParams $params,
private ?ApiKey $apiKey,
private readonly VisitRepositoryInterface $visitRepository,
private readonly string $domain,
private readonly VisitsParams $params,
private readonly ?ApiKey $apiKey,
) {
}

View File

@@ -5,18 +5,20 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $repo,
private VisitsParams $params,
private ?ApiKey $apiKey,
private readonly VisitRepositoryInterface $repo,
private readonly VisitsParams $params,
private readonly ?ApiKey $apiKey,
) {
}

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(

View File

@@ -6,19 +6,21 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
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\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $visitRepository,
private ShortUrlIdentifier $identifier,
private VisitsParams $params,
private ?ApiKey $apiKey,
private readonly VisitRepositoryInterface $visitRepository,
private readonly ShortUrlIdentifier $identifier,
private readonly VisitsParams $params,
private readonly ?ApiKey $apiKey,
) {
}

View File

@@ -5,19 +5,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
/** @extends AbstractCacheableCountPaginatorAdapter<Visit> */
class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $visitRepository,
private string $tag,
private VisitsParams $params,
private ?ApiKey $apiKey,
private readonly VisitRepositoryInterface $visitRepository,
private readonly string $tag,
private readonly VisitsParams $params,
private readonly ?ApiKey $apiKey,
) {
}

View File

@@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Rest\ApiKey\Role;
/** @extends EntitySpecificationRepository<OrphanVisitsCount> */
class OrphanVisitsCountRepository extends EntitySpecificationRepository implements OrphanVisitsCountRepositoryInterface
{
public function countOrphanVisits(VisitsCountFiltering $filtering): int

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Repository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
/** @extends EntitySpecificationRepository<ShortUrl> */
class ShortUrlVisitsCountRepository extends EntitySpecificationRepository implements
ShortUrlVisitsCountRepositoryInterface
{

View File

@@ -8,6 +8,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
/** @extends EntitySpecificationRepository<Visit> */
class VisitDeleterRepository extends EntitySpecificationRepository implements VisitDeleterRepositoryInterface
{
public function deleteShortUrlVisits(ShortUrl $shortUrl): int

View File

@@ -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
* @extends EntitySpecificationRepository<Visit>
*/
class VisitIterationRepository extends EntitySpecificationRepository implements VisitIterationRepositoryInterface
{

View File

@@ -23,6 +23,7 @@ use Shlinkio\Shlink\Rest\ApiKey\Role;
use const PHP_INT_MAX;
/** @extends EntitySpecificationRepository<Visit> */
class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
{
/**

View File

@@ -13,6 +13,9 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
/**
* @extends ObjectRepository<Visit>
*/
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
/**

View File

@@ -5,30 +5,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Fig\Http\Message\RequestMethodInterface;
use IPLib\Address\IPv4;
use IPLib\Factory;
use IPLib\Range\RangeInterface;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
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;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
{
public function __construct(
private readonly VisitsTrackerInterface $visitsTracker,
private readonly TrackingOptions $trackingOptions,
) {
public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions)
{
}
public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void
@@ -63,7 +54,7 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
return false;
}
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
$remoteAddr = ipAddressFromRequest($request);
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
return false;
}
@@ -78,35 +69,10 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
return false;
}
$ip = IPv4::parseString($remoteAddr);
if ($ip === null) {
try {
return IpAddressUtils::ipAddressMatchesGroups($remoteAddr, $this->trackingOptions->disableTrackingFrom);
} catch (InvalidIpFormatException) {
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,
)),
);
}
}

View File

@@ -63,8 +63,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
}
/**
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
* @inheritDoc
*/
public function visitsForShortUrl(
ShortUrlIdentifier $identifier,
@@ -87,8 +86,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
}
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
* @inheritDoc
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
@@ -105,8 +103,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
}
/**
* @return Visit[]|Paginator
* @throws DomainNotFoundException
* @inheritDoc
*/
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
{
@@ -141,6 +138,10 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
}
/**
* @param AdapterInterface<Visit> $adapter
* @return Paginator<Visit>
*/
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
{
$paginator = new Paginator($adapter);

View File

@@ -20,7 +20,7 @@ interface VisitsStatsHelperInterface
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
/**
* @return Visit[]|Paginator
* @return Paginator<Visit>
* @throws ShortUrlNotFoundException
*/
public function visitsForShortUrl(
@@ -30,24 +30,24 @@ interface VisitsStatsHelperInterface
): Paginator;
/**
* @return Visit[]|Paginator
* @return Paginator<Visit>
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @return Visit[]|Paginator
* @return Paginator<Visit>
* @throws DomainNotFoundException
*/
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;
/**
* @return Visit[]|Paginator
* @return Paginator<Visit>
*/
public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
}

View File

@@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
use const ShlinkioTest\Shlink\DESKTOP_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/',
];
$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',
];
}
}
/**

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_filter;
@@ -16,7 +16,7 @@ use function array_values;
class OrphanVisitsCountTrackerTest extends DatabaseTestCase
{
private EntityRepository $repo;
private OrphanVisitsCountRepository $repo;
protected function setUp(): void
{

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_filter;
@@ -17,7 +17,7 @@ use function array_values;
class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase
{
private EntityRepository $repo;
private ShortUrlVisitsCountRepository $repo;
protected function setUp(): void
{

View File

@@ -11,27 +11,29 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Action\RobotsAction;
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
use Shlinkio\Shlink\Core\Options\RobotsOptions;
class RobotsActionTest extends TestCase
{
private RobotsAction $action;
private MockObject & CrawlingHelperInterface $helper;
protected function setUp(): void
{
$this->helper = $this->createMock(CrawlingHelperInterface::class);
$this->action = new RobotsAction($this->helper);
}
#[Test, DataProvider('provideShortCodes')]
public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void
{
public function buildsRobotsLinesFromCrawlableShortCodes(
array $shortCodes,
RobotsOptions $options,
string $expected,
): void {
$this->helper
->expects($this->once())
->expects($options->allowAllShortUrls ? $this->never() : $this->once())
->method('listCrawlableShortCodes')
->willReturn($shortCodes);
$response = $this->action->handle(ServerRequestFactory::fromGlobals());
$response = $this->action($options)->handle(ServerRequestFactory::fromGlobals());
self::assertEquals(200, $response->getStatusCode());
self::assertEquals($expected, $response->getBody()->__toString());
@@ -40,7 +42,7 @@ class RobotsActionTest extends TestCase
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:
# https://www.robotstxt.org/orig.html
@@ -50,7 +52,7 @@ class RobotsActionTest extends TestCase
Allow: /baz
Disallow: /
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:
# https://www.robotstxt.org/orig.html
@@ -62,12 +64,43 @@ class RobotsActionTest extends TestCase
Allow: /baz
Disallow: /
ROBOTS];
yield 'no short codes' => [[], <<<ROBOTS
yield 'no short codes' => [[], new RobotsOptions(), <<<ROBOTS
# For more information about the robots.txt standard, see:
# https://www.robotstxt.org/orig.html
User-agent: *
Disallow: /
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);
}
}

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