Merge pull request #2172 from shlinkio/develop

Release v4.2.0
This commit is contained in:
Alejandro Celaya 2024-08-11 18:33:09 +02:00 committed by GitHub
commit 620cd92d11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 1233 additions and 381 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,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*

View file

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

View file

@ -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",

View file

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

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
return [
'robots' => [
'allow-all-short-urls' => (bool) Config\EnvVars::ROBOTS_ALLOW_ALL_SHORT_URLS->loadFromEnv(false),
'user-agents' => splitByComma(Config\EnvVars::ROBOTS_USER_AGENTS->loadFromEnv()),
],
];

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
@ -79,7 +77,7 @@ services:
shlink_db_postgres:
container_name: shlink_db_postgres
image: postgres:12.2-alpine
image: postgres:16.3-alpine
ports:
- "5434:5432"
volumes:
@ -147,7 +145,7 @@ services:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000 http://localhost:3002 http://127.0.0.1:3002 http://localhost:3005 http://127.0.0.1:3005"
shlink_rabbitmq:
container_name: shlink_rabbitmq

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class EditShortUrlCommand extends Command
{
public const NAME = 'short-url:edit';
private readonly ShortUrlDataInput $shortUrlDataInput;
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(
private readonly ShortUrlServiceInterface $shortUrlService,
private readonly ShortUrlStringifierInterface $stringifier,
) {
parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code to edit',
domainDesc: 'The domain to which the short URL is attached.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Edit an existing short URL');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try {
$shortUrl = $this->shortUrlService->updateShortUrl(
$identifier,
$this->shortUrlDataInput->toShortUrlEdition($input),
);
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
return ExitCode::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error(sprintf('Short URL not found for "%s"', $identifier->__toString()));
if ($io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $io);
}
return ExitCode::EXIT_FAILURE;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use function array_map;
use function array_unique;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Shlinkio\Shlink\Core\splitByComma;
readonly final class ShortUrlDataInput
{
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
$command->addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
} else {
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
}
$command
->addOption(
ShortUrlDataOption::TAGS->value,
ShortUrlDataOption::TAGS->shortcut(),
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the short URL',
)
->addOption(
ShortUrlDataOption::VALID_SINCE->value,
ShortUrlDataOption::VALID_SINCE->shortcut(),
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::VALID_UNTIL->value,
ShortUrlDataOption::VALID_UNTIL->shortcut(),
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::MAX_VISITS->value,
ShortUrlDataOption::MAX_VISITS->shortcut(),
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
ShortUrlDataOption::TITLE->value,
ShortUrlDataOption::TITLE->shortcut(),
InputOption::VALUE_REQUIRED,
'A descriptive title for the short URL.',
)
->addOption(
ShortUrlDataOption::CRAWLABLE->value,
ShortUrlDataOption::CRAWLABLE->shortcut(),
InputOption::VALUE_NONE,
'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
)
->addOption(
ShortUrlDataOption::NO_FORWARD_QUERY->value,
ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
);
}
public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
{
return ShortUrlEdition::fromRawData($this->getCommonData($input));
}
public function toShortUrlCreation(
InputInterface $input,
UrlShortenerOptions $options,
string $customSlugField,
string $shortCodeLengthField,
string $pathPrefixField,
string $findIfExistsField,
string $domainField,
): ShortUrlCreation {
$shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
...$this->getCommonData($input),
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
], $options);
}
private function getCommonData(InputInterface $input): array
{
$longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
$data = [ShortUrlInputFilter::LONG_URL => $longUrl];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
}
if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
}
if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
$maxVisits = $input->getOption('max-visits');
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
}
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
$data[ShortUrlInputFilter::TAGS] = $tags;
}
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
}
if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
$data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
}
if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
}
return $data;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
enum ShortUrlDataOption: string
{
case TAGS = 'tags';
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
case TITLE = 'title';
case CRAWLABLE = 'crawlable';
case NO_FORWARD_QUERY = 'no-forward-query';
public function shortcut(): ?string
{
return match ($this) {
self::TAGS => 't',
self::VALID_SINCE => 's',
self::VALID_UNTIL => 'u',
self::MAX_VISITS => 'm',
self::TITLE => null,
self::CRAWLABLE => 'r',
self::NO_FORWARD_QUERY => 'w',
};
}
public function wasProvided(InputInterface $input): bool
{
$option = sprintf('--%s', $this->value);
$shortcut = $this->shortcut();
return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
}
}

View file

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

View file

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

View file

@ -0,0 +1,74 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\EditShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class EditShortUrlCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & ShortUrlServiceInterface $shortUrlService;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]
public function successMessageIsPrintedIfNoErrorOccurs(): void
{
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(
ShortUrl::createFake(),
);
$this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo');
$this->commandTester->execute(['shortCode' => 'foobar']);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('Short URL "https://s.test/foo" properly edited', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
#[Test]
#[TestWith([OutputInterface::VERBOSITY_NORMAL])]
#[TestWith([OutputInterface::VERBOSITY_VERBOSE])]
#[TestWith([OutputInterface::VERBOSITY_VERY_VERBOSE])]
#[TestWith([OutputInterface::VERBOSITY_DEBUG])]
public function errorIsPrintedInCaseOfFailure(int $verbosity): void
{
$e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('foo'));
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e);
$this->stringifier->expects($this->never())->method('stringify');
$this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('Short URL not found for "foo"', $output);
if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
self::assertStringContainsString('Exception trace:', $output);
} else {
self::assertStringNotContainsString('Exception trace:', $output);
}
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use function sprintf;
class InvalidIpFormatException extends RuntimeException implements ExceptionInterface
{
public static function fromInvalidIp(string $ipAddress): self
{
return new self(sprintf('Provided IP %s does not have the right format. Expected X.X.X.X', $ipAddress));
}
}

View file

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

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use function count;
final readonly class RobotsOptions
{
public function __construct(
public bool $allowAllShortUrls = false,
/** @var string[] */
public array $userAgents = [],
) {
}
public function hasUserAgents(): bool
{
return count($this->userAgents) > 0;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
interface ShortUrlDataTransformerInterface
{
public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use IPLib\Address\IPv4;
use IPLib\Factory;
use IPLib\Range\RangeInterface;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
use function array_keys;
use function array_map;
use function explode;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function str_contains;
final class IpAddressUtils
{
public static function isStaticIpCidrOrWildcard(string $candidate): bool
{
return self::candidateToRange($candidate, ['0', '0', '0', '0']) !== null;
}
/**
* Checks if an IP address matches any of provided groups.
* Every group can be a static IP address (100.200.80.40), a CIDR block (192.168.10.0/24) or a wildcard pattern
* (11.22.*.*).
*
* Matching will happen as follows:
* * Static IP address -> strict equality with provided IP address.
* * CIDR block -> provided IP address is part of that block.
* * Wildcard pattern -> static parts match the corresponding ones in provided IP address.
*
* @param string[] $groups
* @throws InvalidIpFormatException
*/
public static function ipAddressMatchesGroups(string $ipAddress, array $groups): bool
{
$ip = IPv4::parseString($ipAddress);
if ($ip === null) {
throw InvalidIpFormatException::fromInvalidIp($ipAddress);
}
$ipAddressParts = explode('.', $ipAddress);
return some($groups, function (string $group) use ($ip, $ipAddressParts): bool {
$range = self::candidateToRange($group, $ipAddressParts);
return $range !== null && $range->contains($ip);
});
}
/**
* Convert a static IP, CIDR block or wildcard pattern into a Range object
*
* @param string[] $ipAddressParts
*/
private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface
{
return str_contains($candidate, '*')
? self::parseValueWithWildcards($candidate, $ipAddressParts)
: Factory::parseRangeString($candidate);
}
/**
* Try to generate an IP range from a wildcard pattern.
* Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also
* covers cases where wildcards are in between.
*/
private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface
{
$octets = explode('.', $value);
$keys = array_keys($octets);
// Replace wildcard parts with the corresponding ones from the remote address
return Factory::parseRangeString(
implode('.', array_map(
fn (string $part, int $index) => $part === '*' ? $ipAddressParts[$index] : $part,
$octets,
$keys,
)),
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
/**
* Allows iterating large amounts of visits in a memory-efficient way, to use in batch processes
* @extends EntitySpecificationRepository<Visit>
*/
class VisitIterationRepository extends EntitySpecificationRepository implements VisitIterationRepositoryInterface
{

View file

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

View file

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

View file

@ -5,30 +5,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Fig\Http\Message\RequestMethodInterface;
use IPLib\Address\IPv4;
use IPLib\Factory;
use IPLib\Range\RangeInterface;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use function array_keys;
use function array_map;
use function explode;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function str_contains;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
{
public function __construct(
private readonly VisitsTrackerInterface $visitsTracker,
private readonly TrackingOptions $trackingOptions,
) {
public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions)
{
}
public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void
@ -63,7 +54,7 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
return false;
}
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
$remoteAddr = ipAddressFromRequest($request);
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
return false;
}
@ -78,35 +69,10 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
return false;
}
$ip = IPv4::parseString($remoteAddr);
if ($ip === null) {
try {
return IpAddressUtils::ipAddressMatchesGroups($remoteAddr, $this->trackingOptions->disableTrackingFrom);
} catch (InvalidIpFormatException) {
return false;
}
$remoteAddrParts = explode('.', $remoteAddr);
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom;
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
$range = str_contains($value, '*')
? $this->parseValueWithWildcards($value, $remoteAddrParts)
: Factory::parseRangeString($value);
return $range !== null && $ip->matches($range);
});
}
private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface
{
$octets = explode('.', $value);
$keys = array_keys($octets);
// Replace wildcard parts with the corresponding ones from the remote address
return Factory::parseRangeString(
implode('.', array_map(
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
$octets,
$keys,
)),
);
}
}

View file

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

View file

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

View file

@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
@ -86,6 +88,16 @@ class RedirectTest extends ApiTestCase
],
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
];
$clientDetection = require __DIR__ . '/../../../../config/autoload/client-detection.global.php';
foreach ($clientDetection['ip_address_resolution']['headers_to_inspect'] as $header) {
yield sprintf('rule: IP address in "%s" header', $header) => [
[
RequestOptions::HEADERS => [$header => '1.2.3.4'],
],
'https://example.com/static-ip-address',
];
}
}
/**

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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
{

View file

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

View file

@ -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',
];
}
}

View file

@ -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(

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

View file

@ -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
{

View file

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