mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
commit
620cd92d11
119 changed files with 1233 additions and 381 deletions
10
.github/ISSUE_TEMPLATE/Bug.yml
vendored
10
.github/ISSUE_TEMPLATE/Bug.yml
vendored
|
@ -61,7 +61,11 @@ body:
|
|||
label: Minimum steps to reproduce
|
||||
value: |
|
||||
<!--
|
||||
Emphasis in MINIMUM: What is the simplest way to reproduce the bug?
|
||||
Avoid things like "Create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause and the bug may be closed as "not reproducible".
|
||||
If you can provide a simple docker compose config, that's even better.
|
||||
Simple but detailed way to reproduce the bug:
|
||||
|
||||
* Avoid things like "create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause.
|
||||
* Avoid too vague steps or one-liners like "Update from v1 to v2".
|
||||
* Providing the reproduction in the form of a self-contained docker-composer is desirable.
|
||||
|
||||
Failing in any of these will cause the issue to be closed as "not reproducible".
|
||||
-->
|
||||
|
|
2
.github/actions/ci-setup/action.yml
vendored
2
.github/actions/ci-setup/action.yml
vendored
|
@ -44,5 +44,5 @@ runs:
|
|||
ini-values: pcov.directory=module
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install-deps == 'yes' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }}
|
||||
shell: bash
|
||||
|
|
9
.github/workflows/ci-db-tests.yml
vendored
9
.github/workflows/ci-db-tests.yml
vendored
|
@ -10,10 +10,11 @@ on:
|
|||
|
||||
jobs:
|
||||
db-tests:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3']
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
|
@ -31,12 +32,12 @@ jobs:
|
|||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||
- name: Create test database
|
||||
if: ${{ inputs.platform == 'ms' }}
|
||||
run: docker compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
run: docker compose exec -T shlink_db_ms /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- name: Run tests
|
||||
run: composer test:db:${{ inputs.platform }}
|
||||
- name: Upload code coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
|
||||
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
|
||||
with:
|
||||
name: coverage-db
|
||||
path: |
|
||||
|
|
2
.github/workflows/ci-docker-image-build.yml
vendored
2
.github/workflows/ci-docker-image-build.yml
vendored
|
@ -7,7 +7,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
|
7
.github/workflows/ci-tests.yml
vendored
7
.github/workflows/ci-tests.yml
vendored
|
@ -10,10 +10,11 @@ on:
|
|||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3']
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
|
@ -33,7 +34,7 @@ jobs:
|
|||
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
|
||||
- run: composer test:${{ inputs.test-group }}:ci
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.php-version == '8.2' }}
|
||||
if: ${{ matrix.php-version == '8.3' }}
|
||||
with:
|
||||
name: coverage-${{ inputs.test-group }}
|
||||
path: |
|
||||
|
|
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
@ -24,10 +24,10 @@ on:
|
|||
|
||||
jobs:
|
||||
static-analysis:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
php-version: ['8.3']
|
||||
command: ['cs', 'stan', 'swagger:validate']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
@ -66,10 +66,10 @@ jobs:
|
|||
- api-tests
|
||||
- cli-tests
|
||||
- db-tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
php-version: ['8.3']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
@ -94,7 +94,7 @@ jobs:
|
|||
delete-artifacts:
|
||||
needs:
|
||||
- upload-coverage
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v2
|
||||
with:
|
||||
|
|
8
.github/workflows/publish-release.yml
vendored
8
.github/workflows/publish-release.yml
vendored
|
@ -7,10 +7,10 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3']
|
||||
php-version: ['8.2', '8.3'] # TODO 8.4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
|
@ -26,7 +26,7 @@ jobs:
|
|||
|
||||
publish:
|
||||
needs: ['build']
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
|
@ -43,7 +43,7 @@ jobs:
|
|||
|
||||
delete-artifacts:
|
||||
needs: ['publish']
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v2
|
||||
with:
|
||||
|
|
2
.github/workflows/publish-swagger-spec.yml
vendored
2
.github/workflows/publish-swagger-spec.yml
vendored
|
@ -7,7 +7,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
|
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -4,6 +4,33 @@ 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.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*
|
||||
|
|
|
@ -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 .
|
||||
|
|
|
@ -44,17 +44,17 @@
|
|||
"pagerfanta/core": "^3.8",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.1.1",
|
||||
"shlinkio/shlink-common": "^6.1",
|
||||
"shlinkio/shlink-common": "^6.2",
|
||||
"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",
|
||||
|
@ -70,7 +70,7 @@
|
|||
"phpstan/phpstan-symfony": "^1.4",
|
||||
"phpunit/php-code-coverage": "^11.0",
|
||||
"phpunit/phpcov": "^10.0",
|
||||
"phpunit/phpunit": "^11.1",
|
||||
"phpunit/phpunit": "^11.3",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^4.1",
|
||||
|
@ -114,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 -- $*",
|
||||
|
@ -136,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",
|
||||
|
|
|
@ -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,
|
||||
|
|
14
config/autoload/robots.global.php
Normal file
14
config/autoload/robots.global.php
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
return [
|
||||
|
||||
'robots' => [
|
||||
'allow-all-short-urls' => (bool) Config\EnvVars::ROBOTS_ALLOW_ALL_SHORT_URLS->loadFromEnv(false),
|
||||
'user-agents' => splitByComma(Config\EnvVars::ROBOTS_USER_AGENTS->loadFromEnv()),
|
||||
],
|
||||
|
||||
];
|
|
@ -4,40 +4,35 @@ declare(strict_types=1);
|
|||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
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()),
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
];
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_db_mysql:
|
||||
environment:
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_php:
|
||||
user: 1000:1000
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_nginx:
|
||||
container_name: shlink_nginx
|
||||
|
@ -79,7 +77,7 @@ services:
|
|||
|
||||
shlink_db_postgres:
|
||||
container_name: shlink_db_postgres
|
||||
image: postgres:12.2-alpine
|
||||
image: postgres:16.3-alpine
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -33,6 +33,9 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
|||
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$domain = $input->getArgument('domain');
|
||||
|
|
|
@ -4,24 +4,18 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function explode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function sprintf;
|
||||
|
||||
class CreateShortUrlCommand extends Command
|
||||
|
@ -29,6 +23,7 @@ class CreateShortUrlCommand extends Command
|
|||
public const NAME = 'short-url:create';
|
||||
|
||||
private ?SymfonyStyle $io;
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
|
||||
public function __construct(
|
||||
private readonly UrlShortenerInterface $urlShortener,
|
||||
|
@ -36,6 +31,7 @@ class CreateShortUrlCommand extends Command
|
|||
private readonly UrlShortenerOptions $options,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->shortUrlDataInput = new ShortUrlDataInput($this);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
@ -43,26 +39,11 @@ class CreateShortUrlCommand extends Command
|
|||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Generates a short URL for provided long URL and returns it')
|
||||
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the new short URL',
|
||||
)
|
||||
->addOption(
|
||||
'valid-since',
|
||||
's',
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
'valid-until',
|
||||
'u',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.',
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'custom-slug',
|
||||
|
@ -70,30 +51,6 @@ class CreateShortUrlCommand extends Command
|
|||
InputOption::VALUE_REQUIRED,
|
||||
'If provided, this slug will be used instead of generating a short code',
|
||||
)
|
||||
->addOption(
|
||||
'path-prefix',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Prefix to prepend before the generated short code or provided custom slug',
|
||||
)
|
||||
->addOption(
|
||||
'max-visits',
|
||||
'm',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'This will limit the number of visits for this short URL.',
|
||||
)
|
||||
->addOption(
|
||||
'find-if-exists',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'This will force existing matching URL to be returned if found, instead of creating a new one.',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'short-code-length',
|
||||
'l',
|
||||
|
@ -101,16 +58,16 @@ class CreateShortUrlCommand extends Command
|
|||
'The length for generated short code (it will be ignored if --custom-slug was provided).',
|
||||
)
|
||||
->addOption(
|
||||
'crawlable',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
|
||||
'path-prefix',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Prefix to prepend before the generated short code or provided custom slug',
|
||||
)
|
||||
->addOption(
|
||||
'no-forward-query',
|
||||
'w',
|
||||
'find-if-exists',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
|
||||
'This will force existing matching URL to be returned if found, instead of creating a new one.',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -136,32 +93,17 @@ class CreateShortUrlCommand extends Command
|
|||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = $this->getIO($input, $output);
|
||||
$longUrl = $input->getArgument('longUrl');
|
||||
if (empty($longUrl)) {
|
||||
$io->error('A URL was not provided!');
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
|
||||
$explodeWithComma = static fn (string $tag) => explode(',', $tag);
|
||||
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
|
||||
|
||||
try {
|
||||
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => $longUrl,
|
||||
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
|
||||
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
|
||||
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
|
||||
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
|
||||
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
|
||||
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlInputFilter::TAGS => $tags,
|
||||
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
], $this->options));
|
||||
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
|
||||
$input,
|
||||
$this->options,
|
||||
customSlugField: 'custom-slug',
|
||||
shortCodeLengthField: 'short-code-length',
|
||||
pathPrefixField: 'path-prefix',
|
||||
findIfExistsField: 'find-if-exists',
|
||||
domainField: 'domain',
|
||||
));
|
||||
|
||||
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
|
||||
'Short URL properly created, but the real-time updates cannot be notified when generating the '
|
||||
|
@ -169,7 +111,7 @@ class CreateShortUrlCommand extends Command
|
|||
));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
sprintf('Processed long URL: <info>%s</info>', $result->shortUrl->getLongUrl()),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
|
||||
]);
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
|
@ -181,6 +123,6 @@ class CreateShortUrlCommand extends Command
|
|||
|
||||
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
|
||||
{
|
||||
return $this->io ?? ($this->io = new SymfonyStyle($input, $output));
|
||||
return $this->io ??= new SymfonyStyle($input, $output);
|
||||
}
|
||||
}
|
||||
|
|
71
module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
Normal file
71
module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class EditShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:edit';
|
||||
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
public function __construct(
|
||||
private readonly ShortUrlServiceInterface $shortUrlService,
|
||||
private readonly ShortUrlStringifierInterface $stringifier,
|
||||
) {
|
||||
parent::__construct();
|
||||
|
||||
$this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code to edit',
|
||||
domainDesc: 'The domain to which the short URL is attached.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Edit an existing short URL');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
|
||||
try {
|
||||
$shortUrl = $this->shortUrlService->updateShortUrl(
|
||||
$identifier,
|
||||
$this->shortUrlDataInput->toShortUrlEdition($input),
|
||||
);
|
||||
|
||||
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$io->error(sprintf('Short URL not found for "%s"', $identifier->__toString()));
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()?->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -46,6 +46,9 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
|
|
|
@ -9,14 +9,14 @@ use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
|||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
@ -32,8 +32,6 @@ use function sprintf;
|
|||
|
||||
class ListShortUrlsCommand extends Command
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
|
@ -41,7 +39,7 @@ class ListShortUrlsCommand extends Command
|
|||
|
||||
public function __construct(
|
||||
private readonly ShortUrlListServiceInterface $shortUrlService,
|
||||
private readonly DataTransformerInterface $transformer,
|
||||
private readonly ShortUrlDataTransformerInterface $transformer,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->startDateOption = new StartDateOption($this, 'short URLs');
|
||||
|
@ -179,6 +177,7 @@ class ListShortUrlsCommand extends Command
|
|||
|
||||
/**
|
||||
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
|
||||
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||
*/
|
||||
private function renderPage(
|
||||
OutputInterface $output,
|
||||
|
@ -196,7 +195,7 @@ class ListShortUrlsCommand extends Command
|
|||
ShlinkTable::default($output)->render(
|
||||
array_keys($columnsMap),
|
||||
$rows,
|
||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
$all ? null : PagerfantaUtils::formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
);
|
||||
|
||||
return $shortUrls;
|
||||
|
|
|
@ -33,6 +33,9 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
|
|||
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$tag = $input->getArgument('tag');
|
||||
|
|
|
@ -46,6 +46,9 @@ abstract class AbstractVisitsListCommand extends Command
|
|||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Paginator<Visit> $paginator
|
||||
*/
|
||||
private function resolveRowsAndHeaders(Paginator $paginator): array
|
||||
{
|
||||
$extraKeys = [];
|
||||
|
@ -74,6 +77,9 @@ abstract class AbstractVisitsListCommand extends Command
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
|
||||
|
||||
/**
|
||||
|
|
|
@ -30,6 +30,9 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
|||
->setDescription('Returns the list of non-orphan visits.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
|
||||
|
|
|
@ -30,6 +30,9 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
|||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$rawType = $input->getOption('type');
|
||||
|
|
136
module/CLI/src/Input/ShortUrlDataInput.php
Normal file
136
module/CLI/src/Input/ShortUrlDataInput.php
Normal file
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
readonly final class ShortUrlDataInput
|
||||
{
|
||||
public function __construct(Command $command, private bool $longUrlAsOption = false)
|
||||
{
|
||||
if ($longUrlAsOption) {
|
||||
$command->addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
|
||||
} else {
|
||||
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
|
||||
}
|
||||
|
||||
$command
|
||||
->addOption(
|
||||
ShortUrlDataOption::TAGS->value,
|
||||
ShortUrlDataOption::TAGS->shortcut(),
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the short URL',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::VALID_SINCE->value,
|
||||
ShortUrlDataOption::VALID_SINCE->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::VALID_UNTIL->value,
|
||||
ShortUrlDataOption::VALID_UNTIL->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::MAX_VISITS->value,
|
||||
ShortUrlDataOption::MAX_VISITS->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'This will limit the number of visits for this short URL.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::TITLE->value,
|
||||
ShortUrlDataOption::TITLE->shortcut(),
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A descriptive title for the short URL.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::CRAWLABLE->value,
|
||||
ShortUrlDataOption::CRAWLABLE->shortcut(),
|
||||
InputOption::VALUE_NONE,
|
||||
'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::NO_FORWARD_QUERY->value,
|
||||
ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
|
||||
InputOption::VALUE_NONE,
|
||||
'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
|
||||
);
|
||||
}
|
||||
|
||||
public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
|
||||
{
|
||||
return ShortUrlEdition::fromRawData($this->getCommonData($input));
|
||||
}
|
||||
|
||||
public function toShortUrlCreation(
|
||||
InputInterface $input,
|
||||
UrlShortenerOptions $options,
|
||||
string $customSlugField,
|
||||
string $shortCodeLengthField,
|
||||
string $pathPrefixField,
|
||||
string $findIfExistsField,
|
||||
string $domainField,
|
||||
): ShortUrlCreation {
|
||||
$shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
|
||||
return ShortUrlCreation::fromRawData([
|
||||
...$this->getCommonData($input),
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
|
||||
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
|
||||
ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
|
||||
], $options);
|
||||
}
|
||||
|
||||
private function getCommonData(InputInterface $input): array
|
||||
{
|
||||
$longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
|
||||
$data = [ShortUrlInputFilter::LONG_URL => $longUrl];
|
||||
|
||||
// Avoid setting arguments that were not explicitly provided.
|
||||
// This is important when editing short URLs and should not make a difference when creating.
|
||||
if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
|
||||
}
|
||||
if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
|
||||
}
|
||||
if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
}
|
||||
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
|
||||
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
|
||||
$data[ShortUrlInputFilter::TAGS] = $tags;
|
||||
}
|
||||
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
|
||||
}
|
||||
if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
|
||||
}
|
||||
if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
41
module/CLI/src/Input/ShortUrlDataOption.php
Normal file
41
module/CLI/src/Input/ShortUrlDataOption.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
enum ShortUrlDataOption: string
|
||||
{
|
||||
case TAGS = 'tags';
|
||||
case VALID_SINCE = 'valid-since';
|
||||
case VALID_UNTIL = 'valid-until';
|
||||
case MAX_VISITS = 'max-visits';
|
||||
case TITLE = 'title';
|
||||
case CRAWLABLE = 'crawlable';
|
||||
case NO_FORWARD_QUERY = 'no-forward-query';
|
||||
|
||||
public function shortcut(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TAGS => 't',
|
||||
self::VALID_SINCE => 's',
|
||||
self::VALID_UNTIL => 'u',
|
||||
self::MAX_VISITS => 'm',
|
||||
self::TITLE => null,
|
||||
self::CRAWLABLE => 'r',
|
||||
self::NO_FORWARD_QUERY => 'w',
|
||||
};
|
||||
}
|
||||
|
||||
public function wasProvided(InputInterface $input): bool
|
||||
{
|
||||
$option = sprintf('--%s', $this->value);
|
||||
$shortcut = $this->shortcut();
|
||||
|
||||
return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
|
||||
}
|
||||
}
|
|
@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
|||
$this->askMandatory('Query param name?', $io),
|
||||
$this->askOptional('Query param value?', $io),
|
||||
),
|
||||
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
|
||||
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
|
||||
),
|
||||
};
|
||||
|
||||
$continue = $io->confirm('Do you want to add another condition?');
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
74
module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php
Normal file
74
module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\EditShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class EditShortUrlCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & ShortUrlServiceInterface $shortUrlService;
|
||||
private MockObject & ShortUrlStringifierInterface $stringifier;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class);
|
||||
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
|
||||
|
||||
$command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier);
|
||||
$this->commandTester = CliTestUtils::testerForCommand($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successMessageIsPrintedIfNoErrorOccurs(): void
|
||||
{
|
||||
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(
|
||||
ShortUrl::createFake(),
|
||||
);
|
||||
$this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo');
|
||||
|
||||
$this->commandTester->execute(['shortCode' => 'foobar']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString('Short URL "https://s.test/foo" properly edited', $output);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([OutputInterface::VERBOSITY_NORMAL])]
|
||||
#[TestWith([OutputInterface::VERBOSITY_VERBOSE])]
|
||||
#[TestWith([OutputInterface::VERBOSITY_VERY_VERBOSE])]
|
||||
#[TestWith([OutputInterface::VERBOSITY_DEBUG])]
|
||||
public function errorIsPrintedInCaseOfFailure(int $verbosity): void
|
||||
{
|
||||
$e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('foo'));
|
||||
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e);
|
||||
$this->stringifier->expects($this->never())->method('stringify');
|
||||
|
||||
$this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString('Short URL not found for "foo"', $output);
|
||||
if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
|
||||
self::assertStringContainsString('Exception trace:', $output);
|
||||
} else {
|
||||
self::assertStringNotContainsString('Exception trace:', $output);
|
||||
}
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
|
||||
}
|
||||
}
|
|
@ -116,6 +116,7 @@ class RedirectRuleHandlerTest extends TestCase
|
|||
'Language to match?' => 'en-US',
|
||||
'Query param name?' => 'foo',
|
||||
'Query param value?' => 'bar',
|
||||
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
|
||||
default => '',
|
||||
},
|
||||
);
|
||||
|
@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
|
|||
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
true,
|
||||
];
|
||||
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -14,6 +14,8 @@ use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
|||
use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
|
@ -107,7 +109,6 @@ function normalizeLocale(string $locale): string
|
|||
* minimum quality
|
||||
*
|
||||
* @param non-empty-string $acceptLanguage
|
||||
* @param float<0, 1> $minQuality
|
||||
* @return iterable<string>;
|
||||
*/
|
||||
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
|
||||
|
@ -140,21 +141,31 @@ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0):
|
|||
*/
|
||||
function splitLocale(string $locale): array
|
||||
{
|
||||
return array_pad(explode('-', $locale), length: 2, value: null);
|
||||
[$lang, $countryCode] = array_pad(explode('-', $locale), length: 2, value: null);
|
||||
return [$lang, $countryCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (bool) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
|
@ -260,3 +271,21 @@ function enumToString(string $enum): string
|
|||
{
|
||||
return sprintf('["%s"]', implode('", "', enumValues($enum)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split provided string by comma and return a list of the results.
|
||||
* An empty array is returned if provided value is empty
|
||||
*/
|
||||
function splitByComma(?string $value): array
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(trim(...), explode(',', $value));
|
||||
}
|
||||
|
||||
function ipAddressFromRequest(ServerRequestInterface $request): ?string
|
||||
{
|
||||
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
||||
}
|
||||
|
|
|
@ -10,14 +10,15 @@ use Psr\Http\Message\ResponseInterface;
|
|||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Options\RobotsOptions;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
||||
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
||||
readonly class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
||||
{
|
||||
public function __construct(private readonly CrawlingHelperInterface $crawlingHelper)
|
||||
public function __construct(private CrawlingHelperInterface $crawlingHelper, private RobotsOptions $robotsOptions)
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -33,10 +34,20 @@ class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
|||
# For more information about the robots.txt standard, see:
|
||||
# https://www.robotstxt.org/orig.html
|
||||
|
||||
User-agent: *
|
||||
|
||||
ROBOTS;
|
||||
|
||||
$userAgents = $this->robotsOptions->hasUserAgents() ? $this->robotsOptions->userAgents : ['*'];
|
||||
foreach ($userAgents as $userAgent) {
|
||||
yield sprintf('User-agent: %s%s', $userAgent, PHP_EOL);
|
||||
}
|
||||
|
||||
if ($this->robotsOptions->allowAllShortUrls) {
|
||||
// Disallow rest URLs, but allow all short codes
|
||||
yield 'Disallow: /rest/';
|
||||
return;
|
||||
}
|
||||
|
||||
$shortCodes = $this->crawlingHelper->listCrawlableShortCodes();
|
||||
foreach ($shortCodes as $shortCode) {
|
||||
yield sprintf('Allow: /%s%s', $shortCode, PHP_EOL);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
|
|||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @extends EntitySpecificationRepository<Domain> */
|
||||
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -9,6 +9,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterfa
|
|||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @extends ObjectRepository<Domain> */
|
||||
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -8,6 +8,7 @@ use Laminas\InputFilter\InputFilter;
|
|||
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
|
||||
use Shlinkio\Shlink\Common\Validation\InputFactory;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class DomainRedirectsInputFilter extends InputFilter
|
||||
{
|
||||
public const DOMAIN = 'domain';
|
||||
|
|
|
@ -4,21 +4,21 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
|
||||
final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface
|
||||
{
|
||||
public function __construct(private DataTransformerInterface $shortUrlTransformer)
|
||||
public function __construct(private ShortUrlDataTransformerInterface $shortUrlTransformer)
|
||||
{
|
||||
}
|
||||
|
||||
public function newVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($visit->shortUrl),
|
||||
'shortUrl' => $this->transformShortUrl($visit->shortUrl),
|
||||
'visit' => $visit->jsonSerialize(),
|
||||
]);
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
|
|||
$topic = Topic::newShortUrlVisit($shortUrl?->getShortCode());
|
||||
|
||||
return Update::forTopicAndPayload($topic, [
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
||||
'shortUrl' => $this->transformShortUrl($shortUrl),
|
||||
'visit' => $visit->jsonSerialize(),
|
||||
]);
|
||||
}
|
||||
|
@ -47,4 +47,9 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene
|
|||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
private function transformShortUrl(?ShortUrl $shortUrl): array
|
||||
{
|
||||
return $shortUrl === null ? [] : $this->shortUrlTransformer->transform($shortUrl);
|
||||
}
|
||||
}
|
||||
|
|
15
module/Core/src/Exception/InvalidIpFormatException.php
Normal file
15
module/Core/src/Exception/InvalidIpFormatException.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class InvalidIpFormatException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
public static function fromInvalidIp(string $ipAddress): self
|
||||
{
|
||||
return new self(sprintf('Provided IP %s does not have the right format. Expected X.X.X.X', $ipAddress));
|
||||
}
|
||||
}
|
|
@ -26,6 +26,9 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
|||
|
||||
private array $invalidElements;
|
||||
|
||||
/**
|
||||
* @param InputFilterInterface<mixed> $inputFilter
|
||||
*/
|
||||
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
|
||||
{
|
||||
return static::fromArray($inputFilter->getMessages(), $prev);
|
||||
|
|
22
module/Core/src/Options/RobotsOptions.php
Normal file
22
module/Core/src/Options/RobotsOptions.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use function count;
|
||||
|
||||
final readonly class RobotsOptions
|
||||
{
|
||||
public function __construct(
|
||||
public bool $allowAllShortUrls = false,
|
||||
/** @var string[] */
|
||||
public array $userAgents = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function hasUserAgents(): bool
|
||||
{
|
||||
return count($this->userAgents) > 0;
|
||||
}
|
||||
}
|
|
@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
|||
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @implements AdapterInterface<T>
|
||||
*/
|
||||
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
private ?int $count = null;
|
||||
|
|
|
@ -8,9 +8,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
use function Shlinkio\Shlink\Core\normalizeLocale;
|
||||
use function Shlinkio\Shlink\Core\splitLocale;
|
||||
use function sprintf;
|
||||
|
@ -41,6 +43,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||
return new self(RedirectConditionType::DEVICE, $device->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $ipAddressPattern - A static IP address (100.200.80.40), CIDR block (192.168.10.0/24) or wildcard
|
||||
* pattern (11.22.*.*)
|
||||
*/
|
||||
public static function forIpAddress(string $ipAddressPattern): self
|
||||
{
|
||||
return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern);
|
||||
}
|
||||
|
||||
public static function fromRawData(array $rawData): self
|
||||
{
|
||||
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
|
||||
|
@ -59,6 +70,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
|
||||
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
|
||||
RedirectConditionType::DEVICE => $this->matchesDevice($request),
|
||||
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -100,6 +112,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||
return $device !== null && $device->value === strtolower($this->matchValue);
|
||||
}
|
||||
|
||||
private function matchesRemoteIpAddress(ServerRequestInterface $request): bool
|
||||
{
|
||||
$remoteAddress = ipAddressFromRequest($request);
|
||||
return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
@ -119,6 +137,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||
$this->matchKey,
|
||||
$this->matchValue,
|
||||
),
|
||||
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,4 +7,5 @@ enum RedirectConditionType: string
|
|||
case DEVICE = 'device';
|
||||
case LANGUAGE = 'language';
|
||||
case QUERY_PARAM = 'query-param';
|
||||
case IP_ADDRESS = 'ip-address';
|
||||
}
|
||||
|
|
|
@ -12,10 +12,12 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
|
|||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class RedirectRulesInputFilter extends InputFilter
|
||||
{
|
||||
public const REDIRECT_RULES = 'redirectRules';
|
||||
|
@ -43,6 +45,9 @@ class RedirectRulesInputFilter extends InputFilter
|
|||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InputFilter<mixed>
|
||||
*/
|
||||
private static function createRedirectRuleInputFilter(): InputFilter
|
||||
{
|
||||
$redirectRuleInputFilter = new InputFilter();
|
||||
|
@ -59,6 +64,9 @@ class RedirectRulesInputFilter extends InputFilter
|
|||
return $redirectRuleInputFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InputFilter<mixed>
|
||||
*/
|
||||
private static function createRedirectConditionInputFilter(): InputFilter
|
||||
{
|
||||
$redirectConditionInputFilter = new InputFilter();
|
||||
|
@ -71,13 +79,14 @@ class RedirectRulesInputFilter extends InputFilter
|
|||
$redirectConditionInputFilter->add($type);
|
||||
|
||||
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
|
||||
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
|
||||
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
|
||||
return contains($value, enumValues(DeviceType::class));
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
$value->getValidatorChain()->attach(new Callback(
|
||||
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
|
||||
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
|
||||
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
|
||||
// RedirectConditionType::LANGUAGE->value => TODO,
|
||||
default => true,
|
||||
},
|
||||
));
|
||||
$redirectConditionInputFilter->add($value);
|
||||
|
||||
$redirectConditionInputFilter->add(
|
||||
|
|
|
@ -37,8 +37,8 @@ class ShortUrl extends AbstractEntity
|
|||
{
|
||||
/**
|
||||
* @param Collection<int, Tag> $tags
|
||||
* @param Collection<int, Visit> & Selectable $visits
|
||||
* @param Collection<int, ShortUrlVisitsCount> & Selectable $visitsCounts
|
||||
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
|
||||
* @param Collection<int, ShortUrlVisitsCount> & Selectable<int, ShortUrlVisitsCount> $visitsCounts
|
||||
*/
|
||||
private function __construct(
|
||||
private string $longUrl,
|
||||
|
@ -213,7 +213,7 @@ class ShortUrl extends AbstractEntity
|
|||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Visit> & Selectable $visits
|
||||
* @param Collection<int, Visit> & Selectable<int, Visit> $visits
|
||||
* @internal
|
||||
*/
|
||||
public function setVisits(Collection & Selectable $visits): self
|
||||
|
|
|
@ -20,6 +20,7 @@ use function substr;
|
|||
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
|
||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class ShortUrlInputFilter extends InputFilter
|
||||
{
|
||||
// Fields for creation only
|
||||
|
|
|
@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
|||
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
/** @extends InputFilter<mixed> */
|
||||
class ShortUrlsParamsInputFilter extends InputFilter
|
||||
{
|
||||
public const PAGE = 'page';
|
||||
|
|
|
@ -6,11 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter;
|
|||
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @implements AdapterInterface<ShortUrlWithVisitsSummary> */
|
||||
readonly class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
public function __construct(
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
|
|||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class CrawlableShortCodesQuery extends EntitySpecificationRepository implements CrawlableShortCodesQueryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
|||
|
||||
use function sprintf;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class ExpiredShortUrlsRepository extends EntitySpecificationRepository implements ExpiredShortUrlsRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
|||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function sprintf;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -20,6 +20,7 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
|||
use function count;
|
||||
use function strtolower;
|
||||
|
||||
/** @extends EntitySpecificationRepository<ShortUrl> */
|
||||
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
|
||||
{
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl
|
||||
|
|
|
@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
|||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
/** @extends ObjectRepository<ShortUrl> */
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl;
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
|
|||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
@ -21,7 +20,7 @@ readonly class ShortUrlListService implements ShortUrlListServiceInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrlWithVisitsSummary[]|Paginator
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
|
|
|
@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|||
interface ShortUrlListServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return ShortUrlWithVisitsSummary[]|Paginator
|
||||
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
}
|
||||
|
|
|
@ -4,24 +4,17 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
|
||||
/**
|
||||
* @fixme Do not implement DataTransformerInterface, but a separate interface
|
||||
*/
|
||||
readonly class ShortUrlDataTransformer implements DataTransformerInterface
|
||||
readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface
|
||||
{
|
||||
public function __construct(private ShortUrlStringifierInterface $stringifier)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrlWithVisitsSummary|ShortUrl $data
|
||||
*/
|
||||
public function transform($data): array // phpcs:ignore
|
||||
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array
|
||||
{
|
||||
$shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data;
|
||||
return [
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
|
||||
interface ShortUrlDataTransformerInterface
|
||||
{
|
||||
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array;
|
||||
}
|
|
@ -7,9 +7,11 @@ namespace Shlinkio\Shlink\Core\Tag\Entity;
|
|||
use Doctrine\Common\Collections;
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
class Tag extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
/** @var Collections\Collection<int, ShortUrl> */
|
||||
private Collections\Collection $shortUrls;
|
||||
|
||||
public function __construct(private string $name)
|
||||
|
|
|
@ -12,6 +12,10 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
|
|||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @implements AdapterInterface<T>
|
||||
*/
|
||||
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
public function __construct(
|
||||
|
|
|
@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
|
||||
/** @extends AbstractTagsPaginatorAdapter<TagInfo> */
|
||||
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||
{
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
|
|
|
@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
|
||||
/** @extends AbstractTagsPaginatorAdapter<Tag> */
|
||||
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||
{
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
|
|
|
@ -23,6 +23,7 @@ use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
|
|||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
/** @extends EntitySpecificationRepository<Tag> */
|
||||
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
|
||||
{
|
||||
public function deleteByName(array $names): int
|
||||
|
|
|
@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\Core\Tag\Repository;
|
|||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/** @extends ObjectRepository<Tag> */
|
||||
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function deleteByName(array $names): int;
|
||||
|
|
|
@ -11,7 +11,6 @@ use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
|||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
|
||||
|
@ -20,14 +19,14 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
|
|||
use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class TagService implements TagServiceInterface
|
||||
readonly class TagService implements TagServiceInterface
|
||||
{
|
||||
public function __construct(private readonly ORM\EntityManagerInterface $em)
|
||||
public function __construct(private ORM\EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Tag[]|Paginator
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
|
@ -37,7 +36,7 @@ class TagService implements TagServiceInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]|Paginator
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
|
@ -46,6 +45,11 @@ class TagService implements TagServiceInterface
|
|||
return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param AdapterInterface<T> $adapter
|
||||
* @return Paginator<T>
|
||||
*/
|
||||
private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
|
||||
{
|
||||
return (new Paginator($adapter))
|
||||
|
@ -54,8 +58,7 @@ class TagService implements TagServiceInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
* @throws ForbiddenTagOperationException
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
|
||||
{
|
||||
|
@ -69,9 +72,7 @@ class TagService implements TagServiceInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @throws TagNotFoundException
|
||||
* @throws TagConflictException
|
||||
* @throws ForbiddenTagOperationException
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
|
||||
{
|
||||
|
|
|
@ -17,12 +17,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|||
interface TagServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return Tag[]|Paginator
|
||||
* @return Paginator<Tag>
|
||||
*/
|
||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]|Paginator
|
||||
* @return Paginator<TagInfo>
|
||||
*/
|
||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
|
|
85
module/Core/src/Util/IpAddressUtils.php
Normal file
85
module/Core/src/Util/IpAddressUtils.php
Normal file
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Util;
|
||||
|
||||
use IPLib\Address\IPv4;
|
||||
use IPLib\Factory;
|
||||
use IPLib\Range\RangeInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||
|
||||
use function array_keys;
|
||||
use function array_map;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function str_contains;
|
||||
|
||||
final class IpAddressUtils
|
||||
{
|
||||
public static function isStaticIpCidrOrWildcard(string $candidate): bool
|
||||
{
|
||||
return self::candidateToRange($candidate, ['0', '0', '0', '0']) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP address matches any of provided groups.
|
||||
* Every group can be a static IP address (100.200.80.40), a CIDR block (192.168.10.0/24) or a wildcard pattern
|
||||
* (11.22.*.*).
|
||||
*
|
||||
* Matching will happen as follows:
|
||||
* * Static IP address -> strict equality with provided IP address.
|
||||
* * CIDR block -> provided IP address is part of that block.
|
||||
* * Wildcard pattern -> static parts match the corresponding ones in provided IP address.
|
||||
*
|
||||
* @param string[] $groups
|
||||
* @throws InvalidIpFormatException
|
||||
*/
|
||||
public static function ipAddressMatchesGroups(string $ipAddress, array $groups): bool
|
||||
{
|
||||
$ip = IPv4::parseString($ipAddress);
|
||||
if ($ip === null) {
|
||||
throw InvalidIpFormatException::fromInvalidIp($ipAddress);
|
||||
}
|
||||
|
||||
$ipAddressParts = explode('.', $ipAddress);
|
||||
|
||||
return some($groups, function (string $group) use ($ip, $ipAddressParts): bool {
|
||||
$range = self::candidateToRange($group, $ipAddressParts);
|
||||
return $range !== null && $range->contains($ip);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a static IP, CIDR block or wildcard pattern into a Range object
|
||||
*
|
||||
* @param string[] $ipAddressParts
|
||||
*/
|
||||
private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface
|
||||
{
|
||||
return str_contains($candidate, '*')
|
||||
? self::parseValueWithWildcards($candidate, $ipAddressParts)
|
||||
: Factory::parseRangeString($candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to generate an IP range from a wildcard pattern.
|
||||
* Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also
|
||||
* covers cases where wildcards are in between.
|
||||
*/
|
||||
private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface
|
||||
{
|
||||
$octets = explode('.', $value);
|
||||
$keys = array_keys($octets);
|
||||
|
||||
// Replace wildcard parts with the corresponding ones from the remote address
|
||||
return Factory::parseRangeString(
|
||||
implode('.', array_map(
|
||||
fn (string $part, int $index) => $part === '*' ? $ipAddressParts[$index] : $part,
|
||||
$octets,
|
||||
$keys,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -5,9 +5,9 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
use function Shlinkio\Shlink\Core\isCrawler;
|
||||
use function substr;
|
||||
|
||||
|
@ -46,7 +46,7 @@ final class Visitor
|
|||
return new self(
|
||||
$request->getHeaderLine('User-Agent'),
|
||||
$request->getHeaderLine('Referer'),
|
||||
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
|
||||
ipAddressFromRequest($request),
|
||||
$request->getUri()->__toString(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,19 +5,23 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
/**
|
||||
* @extends AbstractCacheableCountPaginatorAdapter<Visit>
|
||||
*/
|
||||
class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
public function __construct(
|
||||
private VisitRepositoryInterface $visitRepository,
|
||||
private string $domain,
|
||||
private VisitsParams $params,
|
||||
private ?ApiKey $apiKey,
|
||||
private readonly VisitRepositoryInterface $visitRepository,
|
||||
private readonly string $domain,
|
||||
private readonly VisitsParams $params,
|
||||
private readonly ?ApiKey $apiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -23,6 +23,7 @@ use Shlinkio\Shlink\Rest\ApiKey\Role;
|
|||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
/** @extends EntitySpecificationRepository<Visit> */
|
||||
class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -13,6 +13,9 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
|||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
|
||||
/**
|
||||
* @extends ObjectRepository<Visit>
|
||||
*/
|
||||
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
19
module/Core/test/Exception/InvalidIpFormatExceptionTest.php
Normal file
19
module/Core/test/Exception/InvalidIpFormatExceptionTest.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Exception;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||
|
||||
class InvalidIpFormatExceptionTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function fromInvalidIp(): void
|
||||
{
|
||||
$e = InvalidIpFormatException::fromInvalidIp('invalid');
|
||||
self::assertEquals('Provided IP invalid does not have the right format. Expected X.X.X.X', $e->getMessage());
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequestFactory;
|
|||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
|
||||
|
@ -28,19 +29,19 @@ class RedirectConditionTest extends TestCase
|
|||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([null, '', false])] // no accept language
|
||||
#[TestWith(['', '', false])] // empty accept language
|
||||
#[TestWith(['*', '', false])] // wildcard accept language
|
||||
#[TestWith(['en', 'en', true])] // single language match
|
||||
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
|
||||
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match
|
||||
#[TestWith(['es_ES', 'es-ES', true])] // single locale match
|
||||
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false])] // too low quality
|
||||
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true])] // quality high enough
|
||||
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
|
||||
#[TestWith(['en-UK', 'en', true])] // only lang
|
||||
#[TestWith(['es-AR', 'en', false])] // different only lang
|
||||
#[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale
|
||||
#[TestWith([null, '', false], 'no accept language')]
|
||||
#[TestWith(['', '', false], 'empty accept language')]
|
||||
#[TestWith(['*', '', false], 'wildcard accept language')]
|
||||
#[TestWith(['en', 'en', true], 'single language match')]
|
||||
#[TestWith(['es, en,fr', 'en', true], 'multiple languages match')]
|
||||
#[TestWith(['es, en-US,fr', 'EN', true], 'multiple locales match')]
|
||||
#[TestWith(['es_ES', 'es-ES', true], 'single locale match')]
|
||||
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false], 'too low quality')]
|
||||
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true], 'quality high enough')]
|
||||
#[TestWith(['en-UK', 'en-uk', true], 'different casing match')]
|
||||
#[TestWith(['en-UK', 'en', true], 'only lang')]
|
||||
#[TestWith(['es-AR', 'en', false], 'different only lang')]
|
||||
#[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')]
|
||||
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals();
|
||||
|
@ -72,4 +73,24 @@ class RedirectConditionTest extends TestCase
|
|||
|
||||
self::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([null, '1.2.3.4', false], 'no remote IP address')]
|
||||
#[TestWith(['1.2.3.4', '1.2.3.4', true], 'static IP address match')]
|
||||
#[TestWith(['4.3.2.1', '1.2.3.4', false], 'no static IP address match')]
|
||||
#[TestWith(['192.168.1.35', '192.168.1.0/24', true], 'CIDR block match')]
|
||||
#[TestWith(['1.2.3.4', '192.168.1.0/24', false], 'no CIDR block match')]
|
||||
#[TestWith(['192.168.1.35', '192.168.1.*', true], 'wildcard pattern match')]
|
||||
#[TestWith(['1.2.3.4', '192.168.1.*', false], 'no wildcard pattern match')]
|
||||
public function matchesRemoteIpAddress(?string $remoteIp, string $ipToMatch, bool $expected): void
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals();
|
||||
if ($remoteIp !== null) {
|
||||
$request = $request->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, $remoteIp);
|
||||
}
|
||||
|
||||
$result = RedirectCondition::forIpAddress($ipToMatch)->matchesRequest($request);
|
||||
|
||||
self::assertEquals($expected, $result);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace RedirectRule\Entity;
|
||||
namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class ShortUrlRedirectRuleTest extends TestCase
|
||||
{
|
||||
#[Test, DataProvider('provideConditions')]
|
||||
|
@ -55,9 +58,12 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||
#[Test, DataProvider('provideConditionMappingCallbacks')]
|
||||
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
|
||||
{
|
||||
$conditions = new ArrayCollection(
|
||||
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
);
|
||||
$conditions = new ArrayCollection([
|
||||
RedirectCondition::forLanguage('en-UK'),
|
||||
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||
RedirectCondition::forDevice(DeviceType::ANDROID),
|
||||
RedirectCondition::forIpAddress('1.2.3.*'),
|
||||
]);
|
||||
$rule = $this->createRule($conditions);
|
||||
|
||||
$result = $rule->mapConditions($callback);
|
||||
|
@ -78,15 +84,27 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||
'matchKey' => 'foo',
|
||||
'matchValue' => 'bar',
|
||||
],
|
||||
[
|
||||
'type' => RedirectConditionType::DEVICE->value,
|
||||
'matchKey' => null,
|
||||
'matchValue' => DeviceType::ANDROID->value,
|
||||
],
|
||||
[
|
||||
'type' => RedirectConditionType::IP_ADDRESS->value,
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.*',
|
||||
],
|
||||
]];
|
||||
yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [
|
||||
'en-UK language is accepted',
|
||||
'query string contains foo=bar',
|
||||
sprintf('device is %s', DeviceType::ANDROID->value),
|
||||
'IP address matches 1.2.3.*',
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection<RedirectCondition> $conditions
|
||||
* @param ArrayCollection<int, RedirectCondition> $conditions
|
||||
*/
|
||||
private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule
|
||||
{
|
||||
|
|
|
@ -51,9 +51,76 @@ class RedirectRulesDataTest extends TestCase
|
|||
],
|
||||
],
|
||||
]]])]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => 'not an IP address',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]])]
|
||||
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
RedirectRulesData::fromRawData($invalidData);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.4',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'static IP')]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.0/24',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'CIDR block')]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.*',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'IP wildcard pattern')]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.*.4',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'in-between IP wildcard pattern')]
|
||||
public function allowsValidDataToBeSet(array $validData): void
|
||||
{
|
||||
$result = RedirectRulesData::fromRawData($validData);
|
||||
self::assertEquals($result->rules, $validData['redirectRules']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
|
@ -88,5 +89,30 @@ class ShortUrlRedirectionResolverTest extends TestCase
|
|||
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||
'https://example.com/from-rule',
|
||||
];
|
||||
yield 'matching static IP address' => [
|
||||
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.3.4'),
|
||||
RedirectCondition::forIpAddress('1.2.3.4'),
|
||||
'https://example.com/from-rule',
|
||||
];
|
||||
yield 'matching CIDR block' => [
|
||||
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '192.168.1.35'),
|
||||
RedirectCondition::forIpAddress('192.168.1.0/24'),
|
||||
'https://example.com/from-rule',
|
||||
];
|
||||
yield 'matching wildcard IP address' => [
|
||||
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.5.5'),
|
||||
RedirectCondition::forIpAddress('1.2.*.*'),
|
||||
'https://example.com/from-rule',
|
||||
];
|
||||
yield 'non-matching IP address' => [
|
||||
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '4.3.2.1'),
|
||||
RedirectCondition::forIpAddress('1.2.3.4'),
|
||||
'https://example.com/foo/bar',
|
||||
];
|
||||
yield 'missing remote IP address' => [
|
||||
$request(),
|
||||
RedirectCondition::forIpAddress('1.2.3.4'),
|
||||
'https://example.com/foo/bar',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,6 +103,9 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
|
|||
self::assertEquals('Resolved "title"', $result->title);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InvocationMocker<ClientInterface>
|
||||
*/
|
||||
private function expectRequestToBeCalled(): InvocationMocker
|
||||
{
|
||||
return $this->httpClient->expects($this->once())->method('request')->with(
|
||||
|
|
26
module/Core/test/Util/IpAddressUtilsTest.php
Normal file
26
module/Core/test/Util/IpAddressUtilsTest.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Util;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
class IpAddressUtilsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[TestWith(['', false], 'empty')]
|
||||
#[TestWith(['invalid', false], 'invalid')]
|
||||
#[TestWith(['1.2.3.4', true], 'static IP')]
|
||||
#[TestWith(['456.2.385.4', false], 'invalid IP')]
|
||||
#[TestWith(['192.168.1.0/24', true], 'CIDR block')]
|
||||
#[TestWith(['1.2.*.*', true], 'wildcard pattern')]
|
||||
#[TestWith(['1.2.*.1', true], 'in-between wildcard pattern')]
|
||||
public function isStaticIpCidrOrWildcardReturnsExpectedResult(string $candidate, bool $expected): void
|
||||
{
|
||||
self::assertEquals($expected, IpAddressUtils::isStaticIpCidrOrWildcard($candidate));
|
||||
}
|
||||
}
|
|
@ -92,6 +92,21 @@ class RequestTrackerTest extends TestCase
|
|||
$this->requestTracker->trackIfApplicable($shortUrl, $this->request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trackingHappensOverShortUrlsWhenRemoteAddressIsInvalid(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl(self::LONG_URL);
|
||||
$this->visitsTracker->expects($this->once())->method('track')->with(
|
||||
$shortUrl,
|
||||
$this->isInstanceOf(Visitor::class),
|
||||
);
|
||||
|
||||
$this->requestTracker->trackIfApplicable($shortUrl, ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
IpAddressMiddlewareFactory::REQUEST_ATTR,
|
||||
'invalid',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function baseUrlErrorIsTracked(): void
|
||||
{
|
||||
|
|
|
@ -7,10 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
|
@ -18,7 +18,7 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
|
|||
{
|
||||
public function __construct(
|
||||
private readonly UrlShortenerInterface $urlShortener,
|
||||
private readonly DataTransformerInterface $transformer,
|
||||
private readonly ShortUrlDataTransformerInterface $transformer,
|
||||
protected readonly UrlShortenerOptions $urlShortenerOptions,
|
||||
) {
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue