Merge pull request #2283 from shlinkio/develop

Release 4.3.0
This commit is contained in:
Alejandro Celaya
2024-11-24 14:31:16 +01:00
committed by GitHub
334 changed files with 3071 additions and 1821 deletions

View File

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

View File

@@ -14,7 +14,6 @@ jobs:
strategy:
matrix:
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '8.4' }}
env:
LC_ALL: C
steps:

View File

@@ -14,7 +14,6 @@ jobs:
strategy:
matrix:
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:

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3'] # TODO 8.4
php-version: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'

View File

@@ -4,12 +4,58 @@ 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.3.0] - 2024-11-24
### Added
* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4.
* [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it.
* [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`.
This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag.
* [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system.
* `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor.
* `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor.
* [#2032](https://github.com/shlinkio/shlink/issues/2032) Save the URL to which a visitor is redirected when a visit is tracked.
The value is exposed in the API as a new `redirectUrl` field for visit objects.
This is useful to know where a visitor was redirected for a short URL with dynamic redirect rules, for special redirects, or simply in case the long URL was changed over time, and you still want to know where visitors were redirected originally.
Some visits may not have a redirect URL if a redirect didn't happen, like for orphan visits when no special redirects are configured, or when a visit is tracked as part of the pixel action.
### Changed
* [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text.
As a side effect, API key names have now become more important, and are considered unique.
When people update to this Shlink version, existing API keys will be hashed for everything to continue working.
In order to avoid data to be lost, plain-text keys will be written in the `name` field, either together with any existing name, or as the name itself. Then users are responsible for renaming them using the new `api-key:rename` command.
For newly created API keys, it is recommended to provide a name, but if not provided, a name will be generated from a redacted version of the new API key.
* Update to Shlink PHP coding standard 2.4
* Update to `hidehalo/nanoid-php` 2.0
* Update to PHPStan 2.0
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2264](https://github.com/shlinkio/shlink/issues/2264) Fix visits counts not being deleted when deleting short URL or orphan visits.
## [4.2.5] - 2024-11-03
### Added
* *Nothing*
### Changed
* *Nothing*
* Update to Shlink PHP coding standard 2.4
### Deprecated
* *Nothing*

View File

@@ -1,4 +1,4 @@
FROM php:8.3-alpine3.19 as base
FROM php:8.3-alpine3.20 AS base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
@@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ENV USER_ID '1001'
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
ENV LC_ALL 'C'
WORKDIR /etc/shlink

View File

@@ -7,7 +7,7 @@
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.

View File

@@ -18,7 +18,8 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.3",
"acelaya/crawler-detect": "^1.3",
"acelaya/ip-address-middleware": "^2.4",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
@@ -27,9 +28,7 @@
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.9",
"hidehalo/nanoid-php": "^1.1",
"jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.9",
"hidehalo/nanoid-php": "^2.0",
"laminas/laminas-config-aggregator": "^1.15",
"laminas/laminas-diactoros": "^3.5",
"laminas/laminas-inputfilter": "^2.30",
@@ -39,17 +38,17 @@
"mezzio/mezzio": "^3.20",
"mezzio/mezzio-fastroute": "^3.12",
"mezzio/mezzio-problem-details": "^1.15",
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^4.8",
"mlocati/ip-lib": "^1.18.1",
"mobiledetect/mobiledetectlib": "4.8.x-dev#920c549 as 4.9",
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "^6.5",
"shlinkio/shlink-config": "^3.3",
"shlinkio/shlink-common": "^6.6",
"shlinkio/shlink-config": "^3.4",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.3.2",
"shlinkio/shlink-installer": "^9.2",
"shlinkio/shlink-ip-geolocation": "^4.1",
"shlinkio/shlink-installer": "^9.3",
"shlinkio/shlink-ip-geolocation": "^4.2",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2024.1",
"spiral/roadrunner-cli": "^2.6",
@@ -64,16 +63,16 @@
"require-dev": {
"devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-doctrine": "^1.5",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-symfony": "^1.4",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.4",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1.1",
"shlinkio/php-coding-standard": "~2.4.0",
"shlinkio/shlink-test-utils": "^4.2",
"symfony/var-dumper": "^7.1",
"veewee/composer-run-parallel": "^1.4"
},

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
return [
'ip_address_resolution' => [
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
];

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
return [
// Configuration for RKA\Middleware\IpAddress
'rka' => [
'ip_address' => [
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
'check_proxy_headers' => true,
'trusted_proxies' => [],
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
],
'dependencies' => [
'factories' => [
IpAddress::class => IpAddressFactory::class,
],
],
];

View File

@@ -11,6 +11,7 @@ use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
return [
@@ -67,8 +68,11 @@ return [
],
'not-found' => [
'middleware' => [
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
// These two middlewares are in front of other tracking actions.
// Putting them here for orphan visits tracking
IpAddress::class,
IpGeolocationMiddleware::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,

View File

@@ -8,6 +8,7 @@ use Fig\Http\Message\RequestMethodInterface;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action as CoreAction;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware;
use Shlinkio\Shlink\Rest\Action;
use Shlinkio\Shlink\Rest\ConfigProvider;
@@ -88,6 +89,7 @@ return (static function (): array {
'path' => '/{shortCode}/track',
'middleware' => [
IpAddress::class,
IpGeolocationMiddleware::class,
CoreAction\PixelAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
@@ -105,6 +107,7 @@ return (static function (): array {
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),
'middleware' => [
IpAddress::class,
IpGeolocationMiddleware::class,
TrimTrailingSlashMiddleware::class,
CoreAction\RedirectAction::class,
],

View File

@@ -21,3 +21,5 @@ const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';

View File

@@ -3,7 +3,7 @@
set -ex
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
curl https://packages.microsoft.com/config/ubuntu/24.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install msodbcsql18
# apt-get install unixodbc-dev

View File

@@ -1,10 +1,10 @@
FROM php:8.3-fpm-alpine3.19
FROM php:8.3-fpm-alpine3.20
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.23
ENV APCU_VERSION 5.1.24
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
RUN apk update

View File

@@ -1,10 +1,9 @@
FROM php:8.3-alpine3.19
FROM php:8.3-alpine3.20
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.23
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
RUN apk update
@@ -36,16 +35,6 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu \
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
&& docker-php-ext-configure apcu \
&& docker-php-ext-install apcu \
&& rm /tmp/apcu.tar.gz \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \

View File

@@ -141,6 +141,14 @@
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
},
"forwardQuery": {
"type": "boolean",
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
},
"hasRedirectRules": {
"type": "boolean",
"description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules."
}
},
"example": {
@@ -164,7 +172,9 @@
},
"domain": "example.com",
"title": "The title",
"crawlable": false
"crawlable": false,
"forwardQuery": false,
"hasRedirectRules": true
}
},
"ShortUrlMeta": {
@@ -237,6 +247,11 @@
"type": "string",
"nullable": true,
"description": "The originally visited URL that triggered the tracking of this visit"
},
"redirectUrl": {
"type": "string",
"nullable": true,
"description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking"
}
},
"example": {

View File

@@ -15,7 +15,14 @@
"properties": {
"type": {
"type": "string",
"enum": ["device", "language", "query-param", "ip-address"],
"enum": [
"device",
"language",
"query-param",
"ip-address",
"geolocation-country-code",
"geolocation-city-name"
],
"description": "The type of the condition, which will determine the logic used to match it"
},
"matchKey": {

View File

@@ -11,7 +11,8 @@
"domain",
"title",
"crawlable",
"forwardQuery"
"forwardQuery",
"hasRedirectRules"
],
"properties": {
"shortCode": {
@@ -59,6 +60,10 @@
"forwardQuery": {
"type": "boolean",
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
},
"hasRedirectRules": {
"type": "boolean",
"description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules."
}
}
}

View File

@@ -25,6 +25,10 @@
"visitedUrl": {
"type": ["string", "null"],
"description": "The originally visited URL that triggered the tracking of this visit"
},
"redirectUrl": {
"type": ["string", "null"],
"description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking"
}
}
}

View File

@@ -125,6 +125,15 @@
"false"
]
}
},
{
"name": "domain",
"in": "query",
"description": "Get short URLs for this particular domain only. Use **DEFAULT** keyword for default domain.",
"required": false,
"schema": {
"type": "string"
}
}
],
"security": [
@@ -180,7 +189,9 @@
},
"domain": null,
"title": "Welcome to Steam",
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": true
},
{
"shortCode": "12Kb3",
@@ -202,7 +213,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": false
},
{
"shortCode": "123bA",
@@ -222,7 +235,9 @@
},
"domain": "example.com",
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": false,
"hasRedirectRules": true
}
],
"pagination": {
@@ -337,7 +352,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": false
}
}
}

View File

@@ -72,7 +72,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": false
}
},
"text/plain": {

View File

@@ -50,7 +50,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": true
}
}
}
@@ -163,7 +165,9 @@
},
"domain": null,
"title": "Shlink - The URL shortener",
"crawlable": false
"crawlable": false,
"forwardQuery": false,
"hasRedirectRules": true
}
}
}

View File

@@ -28,6 +28,7 @@ return [
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,

View File

@@ -59,6 +59,7 @@ return [
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@@ -120,6 +121,7 @@ return [
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],

View File

@@ -6,39 +6,99 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
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 Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
class DisableKeyCommand extends Command
{
public const NAME = 'api-key:disable';
public function __construct(private ApiKeyServiceInterface $apiKeyService)
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription('Disables an API key.')
->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable');
$help = <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
plain-text key.
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled. In that case <comment>--by-name</comment> is also
required, to indicate the first argument is the API key name and not the plain-text key:
<info>%command.full_name% the_key_name --by-name</info>
You can pass the plain-text key to be disabled, but that is <options=bold>DEPRECATED</>. In next major version,
the argument will always be assumed to be the name:
<info>%command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143</info>
HELP;
$this
->setName(self::NAME)
->setDescription('Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)')
->addArgument(
'keyOrName',
InputArgument::OPTIONAL,
'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
)
->addOption(
'by-name',
mode: InputOption::VALUE_NONE,
description: 'Indicates the first argument is the API key name, not the plain-text key.',
)
->setHelp($help);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$keyOrName = $input->getArgument('keyOrName');
if ($keyOrName === null) {
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
$name = (new SymfonyStyle($input, $output))->choice(
'What API key do you want to disable?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('keyOrName', $name);
$input->setOption('by-name', true);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$apiKey = $input->getArgument('apiKey');
$keyOrName = $input->getArgument('keyOrName');
$byName = $input->getOption('by-name');
$io = new SymfonyStyle($input, $output);
if (! $keyOrName) {
$io->warning('An API key name was not provided.');
return ExitCode::EXIT_WARNING;
}
try {
$this->apiKeyService->disable($apiKey);
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
if ($byName) {
$this->apiKeyService->disableByName($keyOrName);
} else {
$this->apiKeyService->disableByKey($keyOrName);
}
$io->success(sprintf('API key "%s" properly disabled', $keyOrName));
return ExitCode::EXIT_SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());

View File

@@ -100,23 +100,26 @@ class GenerateKeyCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$expirationDate = $input->getOption('expiration-date');
$apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams(
$apiKeyMeta = ApiKeyMeta::fromParams(
name: $input->getOption('name'),
expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
roleDefinitions: $this->roleResolver->determineRoles($input),
));
);
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
$apiKey = $this->apiKeyService->create($apiKeyMeta);
$io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key));
if ($input->isInteractive()) {
$io->warning('Save the key in a secure location. You will not be able to get it afterwards.');
}
if (! ApiKey::isAdmin($apiKey)) {
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
null,
'Roles',
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, indentSize: 0)]),
headerTitle: 'Roles',
);
}

View File

@@ -54,7 +54,7 @@ class ListKeysCommand extends Command
$messagePattern = $this->determineMessagePattern($apiKey);
// Set columns for this row
$rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name ?? '-')];
$rowData = [sprintf($messagePattern, $apiKey->name ?? '-')];
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
@@ -67,7 +67,6 @@ class ListKeysCommand extends Command
}, $this->apiKeyService->listKeys($enabledOnly));
ShlinkTable::withRowSeparators($output)->render(array_filter([
'Key',
'Name',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
class RenameApiKeyCommand extends Command
{
public const NAME = 'api-key:rename';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Renames an API key by name')
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the API key to rename')
->addArgument('newName', InputArgument::REQUIRED, 'New name to set to the API key');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
if ($oldName === null) {
$apiKeys = $this->apiKeyService->listKeys();
$requestedOldName = $io->choice(
'What API key do you want to rename?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('oldName', $requestedOldName);
}
if ($newName === null) {
$requestedNewName = $io->ask(
'What is the new name you want to set?',
validator: static fn (string|null $value): string => $value !== null
? $value
: throw new InvalidArgumentException('The new name cannot be empty'),
);
$input->setArgument('newName', $requestedNewName);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
$this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName));
$io->success('API key properly renamed');
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -26,7 +26,7 @@ class ReadEnvVarCommand extends Command
/** @var Closure(string $envVar): mixed */
private readonly Closure $loadEnvVar;
public function __construct(?Closure $loadEnvVar = null)
public function __construct(Closure|null $loadEnvVar = null)
{
$this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv();
parent::__construct();

View File

@@ -74,7 +74,7 @@ class DomainRedirectsCommand extends Command
$domainAuthority = $input->getArgument('domain');
$domain = $this->domainService->findByAuthority($domainAuthority);
$ask = static function (string $message, ?string $current) use ($io): ?string {
$ask = static function (string $message, string|null $current) use ($io): string|null {
if ($current === null) {
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
}

View File

@@ -22,7 +22,7 @@ class CreateShortUrlCommand extends Command
{
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
private SymfonyStyle $io;
private readonly ShortUrlDataInput $shortUrlDataInput;
public function __construct(

View File

@@ -10,9 +10,10 @@ use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
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\ShortUrlWithDeps;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
@@ -64,6 +65,12 @@ class ListShortUrlsCommand extends Command
InputOption::VALUE_REQUIRED,
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
)
->addOption(
'tags',
't',
@@ -111,14 +118,9 @@ class ListShortUrlsCommand extends Command
'show-api-key',
'k',
InputOption::VALUE_NONE,
'Whether to display the API key from which the URL was generated or not.',
)
->addOption(
'show-api-key-name',
'm',
InputOption::VALUE_NONE,
'Whether to display the API key name from which the URL was generated or not.',
)
->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key')
->addOption(
'all',
'a',
@@ -134,6 +136,7 @@ class ListShortUrlsCommand extends Command
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$domain = $input->getOption('domain');
$tags = $input->getOption('tags');
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
@@ -145,6 +148,7 @@ class ListShortUrlsCommand extends Command
$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::DOMAIN => $domain,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
@@ -177,7 +181,7 @@ class ListShortUrlsCommand extends Command
/**
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
* @return Paginator<ShortUrlWithVisitsSummary>
* @return Paginator<ShortUrlWithDeps>
*/
private function renderPage(
OutputInterface $output,
@@ -187,7 +191,7 @@ class ListShortUrlsCommand extends Command
): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params);
$rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) {
$rows = map([...$shortUrls], function (ShortUrlWithDeps $shortUrl) use ($columnsMap) {
$serializedShortUrl = $this->transformer->transform($shortUrl);
return map($columnsMap, fn (callable $call) => $call($serializedShortUrl, $shortUrl->shortUrl));
});
@@ -201,7 +205,7 @@ class ListShortUrlsCommand extends Command
return $shortUrls;
}
private function processOrderBy(InputInterface $input): ?string
private function processOrderBy(InputInterface $input): string|null
{
$orderBy = $input->getOption('order-by');
if (empty($orderBy)) {
@@ -231,14 +235,10 @@ class ListShortUrlsCommand extends Command
}
if ($input->getOption('show-domain')) {
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()?->authority ?? 'DEFAULT';
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->authorApiKey?->__toString() ?? '';
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
$shortUrl->authorApiKey?->name;
}

View File

@@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -40,7 +40,7 @@ class RenameTagCommand extends Command
$newName = $input->getArgument('newName');
try {
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
return ExitCode::EXIT_SUCCESS;
} catch (TagNotFoundException | TagConflictException $e) {

View File

@@ -61,8 +61,8 @@ abstract class AbstractVisitsListCommand extends Command
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown',
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
];

View File

@@ -20,7 +20,7 @@ class DownloadGeoLiteDbCommand extends Command
{
public const NAME = 'visit:download-db';
private ?ProgressBar $progressBar = null;
private ProgressBar|null $progressBar = null;
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
{

View File

@@ -13,12 +13,12 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{
private bool $olderDbExists;
private function __construct(string $message, ?Throwable $previous = null)
private function __construct(string $message, Throwable|null $previous = null)
{
parent::__construct($message, previous: $previous);
}
public static function withOlderDb(?Throwable $prev = null): self
public static function withOlderDb(Throwable|null $prev = null): self
{
$e = new self(
'An error occurred while updating geolocation database, but an older DB is already present.',
@@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
return $e;
}
public static function withoutOlderDb(?Throwable $prev = null): self
public static function withoutOlderDb(Throwable|null $prev = null): self
{
$e = new self(
'An error occurred while updating geolocation database, and an older version could not be found.',

View File

@@ -40,9 +40,11 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult
{
if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) {
public function checkDbUpdate(
callable|null $beforeDownload = null,
callable|null $handleProgress = null,
): GeolocationResult {
if (! $this->trackingOptions->isGeolocationRelevant()) {
return GeolocationResult::CHECK_SKIPPED;
}
@@ -59,7 +61,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult
private function downloadIfNeeded(callable|null $beforeDownload, callable|null $handleProgress): GeolocationResult
{
if (! $this->dbUpdater->databaseFileExists()) {
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
@@ -105,8 +107,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
*/
private function downloadNewDb(
bool $olderDbExists,
?callable $beforeDownload,
?callable $handleProgress,
callable|null $beforeDownload,
callable|null $handleProgress,
): GeolocationResult {
if ($beforeDownload !== null) {
$beforeDownload($olderDbExists);
@@ -124,7 +126,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
}
}
private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable
private function wrapHandleProgressCallback(callable|null $handleProgress, bool $olderDbExists): callable|null
{
if ($handleProgress === null) {
return null;

View File

@@ -12,7 +12,7 @@ interface GeolocationDbUpdaterInterface
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(
?callable $beforeDownload = null,
?callable $handleProgress = null,
callable|null $beforeDownload = null,
callable|null $handleProgress = null,
): GeolocationResult;
}

View File

@@ -21,7 +21,7 @@ readonly class DateOption
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
$value = $input->getOption($this->name);
if (empty($value) || ! is_string($value)) {

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
readonly final class EndDateOption
final readonly class EndDateOption
{
private DateOption $dateOption;
@@ -23,7 +23,7 @@ readonly final class EndDateOption
));
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
return $this->dateOption->get($input, $output);
}

View File

@@ -18,7 +18,7 @@ use function array_unique;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Shlinkio\Shlink\Core\splitByComma;
readonly final class ShortUrlDataInput
final readonly class ShortUrlDataInput
{
public function __construct(Command $command, private bool $longUrlAsOption = false)
{

View File

@@ -18,7 +18,7 @@ enum ShortUrlDataOption: string
case CRAWLABLE = 'crawlable';
case NO_FORWARD_QUERY = 'no-forward-query';
public function shortcut(): ?string
public function shortcut(): string|null
{
return match ($this) {
self::TAGS => 't',

View File

@@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
readonly final class ShortUrlIdentifierInput
final readonly class ShortUrlIdentifierInput
{
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
{
@@ -19,7 +19,7 @@ readonly final class ShortUrlIdentifierInput
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
}
public function shortCode(InputInterface $input): ?string
public function shortCode(InputInterface $input): string|null
{
return $input->getArgument('shortCode');
}

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
readonly final class StartDateOption
final readonly class StartDateOption
{
private DateOption $dateOption;
@@ -23,7 +23,7 @@ readonly final class StartDateOption
));
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
return $this->dateOption->get($input, $output);
}

View File

@@ -33,7 +33,7 @@ use const STR_PAD_LEFT;
class RedirectRuleHandler implements RedirectRuleHandlerInterface
{
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null
{
$amountOfRules = count($rules);
@@ -111,6 +111,12 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode(
$this->askMandatory('Country code to match?', $io),
),
RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName(
$this->askMandatory('City name to match?', $io),
)
};
$continue = $io->confirm('Do you want to add another condition?');
@@ -213,7 +219,7 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
private function askMandatory(string $message, StyleInterface $io): string
{
return $io->ask($message, validator: function (?string $answer): string {
return $io->ask($message, validator: function (string|null $answer): string {
if ($answer === null) {
throw new InvalidArgumentException('The value is mandatory');
}
@@ -223,6 +229,6 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
private function askOptional(string $message, StyleInterface $io): string
{
return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer));
return $io->ask($message, validator: fn (string|null $answer) => $answer === null ? '' : trim($answer));
}
}

View File

@@ -16,5 +16,5 @@ interface RedirectRuleHandlerInterface
* @param ShortUrlRedirectRule[] $rules
* @return ShortUrlRedirectRule[]|null - A new list of rules to save, or null if no changes should be saved
*/
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array;
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null;
}

View File

@@ -20,7 +20,7 @@ class ProcessRunner implements ProcessRunnerInterface
{
private Closure $createProcess;
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
public function __construct(private ProcessHelper $helper, callable|null $createProcess = null)
{
$this->createProcess = $createProcess !== null
? $createProcess(...)

View File

@@ -34,8 +34,12 @@ final class ShlinkTable
return new self($baseTable);
}
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
{
public function render(
array $headers,
array $rows,
string|null $footerTitle = null,
string|null $headerTitle = null,
): void {
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class CreateShortUrlTest extends CliTestCase
@@ -26,6 +27,6 @@ class CreateShortUrlTest extends CliTestCase
self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output);
[$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]);
self::assertStringContainsString('DEFAULT', $listOutput);
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput);
}
}

View File

@@ -6,6 +6,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Importer\Command\ImportCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
@@ -66,10 +67,10 @@ class ImportShortUrlsTest extends CliTestCase
[$listOutput1] = $this->exec(
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'],
);
self::assertStringContainsString('DEFAULT', $listOutput1);
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1);
[$listOutput1] = $this->exec(
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'],
);
self::assertStringContainsString('DEFAULT', $listOutput1);
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1);
}
}

View File

@@ -26,38 +26,38 @@ class ListApiKeysTest extends CliTestCase
{
$expiredApiKeyDate = Chronos::now()->subDays(1)->startOfDay()->toAtomString();
$enabledOnlyOutput = <<<OUT
+--------------------+------+---------------------------+--------------------------+
| Key | Name | Expiration date | Roles |
+--------------------+------+---------------------------+--------------------------+
| valid_api_key | - | - | Admin |
+--------------------+------+---------------------------+--------------------------+
| expired_api_key | - | {$expiredApiKeyDate} | Admin |
+--------------------+------+---------------------------+--------------------------+
| author_api_key | - | - | Author only |
+--------------------+------+---------------------------+--------------------------+
| domain_api_key | - | - | Domain only: example.com |
+--------------------+------+---------------------------+--------------------------+
| no_orphans_api_key | - | - | No orphan visits |
+--------------------+------+---------------------------+--------------------------+
+--------------------+---------------------------+--------------------------+
| Name | Expiration date | Roles |
+--------------------+---------------------------+--------------------------+
| valid_api_key | - | Admin |
+--------------------+---------------------------+--------------------------+
| expired_api_key | {$expiredApiKeyDate} | Admin |
+--------------------+---------------------------+--------------------------+
| author_api_key | - | Author only |
+--------------------+---------------------------+--------------------------+
| domain_api_key | - | Domain only: example.com |
+--------------------+---------------------------+--------------------------+
| no_orphans_api_key | - | No orphan visits |
+--------------------+---------------------------+--------------------------+
OUT;
yield 'no flags' => [[], <<<OUT
+--------------------+------+------------+---------------------------+--------------------------+
| Key | Name | Is enabled | Expiration date | Roles |
+--------------------+------+------------+---------------------------+--------------------------+
| valid_api_key | - | +++ | - | Admin |
+--------------------+------+------------+---------------------------+--------------------------+
| disabled_api_key | - | --- | - | Admin |
+--------------------+------+------------+---------------------------+--------------------------+
| expired_api_key | - | --- | {$expiredApiKeyDate} | Admin |
+--------------------+------+------------+---------------------------+--------------------------+
| author_api_key | - | +++ | - | Author only |
+--------------------+------+------------+---------------------------+--------------------------+
| domain_api_key | - | +++ | - | Domain only: example.com |
+--------------------+------+------------+---------------------------+--------------------------+
| no_orphans_api_key | - | +++ | - | No orphan visits |
+--------------------+------+------------+---------------------------+--------------------------+
+--------------------+------------+---------------------------+--------------------------+
| Name | Is enabled | Expiration date | Roles |
+--------------------+------------+---------------------------+--------------------------+
| valid_api_key | +++ | - | Admin |
+--------------------+------------+---------------------------+--------------------------+
| disabled_api_key | --- | - | Admin |
+--------------------+------------+---------------------------+--------------------------+
| expired_api_key | --- | {$expiredApiKeyDate} | Admin |
+--------------------+------------+---------------------------+--------------------------+
| author_api_key | +++ | - | Author only |
+--------------------+------------+---------------------------+--------------------------+
| domain_api_key | +++ | - | Domain only: example.com |
+--------------------+------------+---------------------------+--------------------------+
| no_orphans_api_key | +++ | - | No orphan visits |
+--------------------+------------+---------------------------+--------------------------+
OUT];
yield '-e' => [['-e'], $enabledOnlyOutput];

View File

@@ -70,6 +70,23 @@ class ListShortUrlsTest extends CliTestCase
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
+--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'non-default domain' => [['--domain=example.com'], <<<OUTPUT
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
+------------+-------+---------------------------+-------------------------------------------- Page 1 of 1 --------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'default domain' => [['-d DEFAULT'], <<<OUTPUT
+------------+---------------+----------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+------------+---------------+----------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
OUTPUT];
// phpcs:enable
}
}

View File

@@ -8,7 +8,10 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
@@ -28,30 +31,103 @@ class DisableKeyCommandTest extends TestCase
public function providedApiKeyIsDisabled(): void
{
$apiKey = 'abcd1234';
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey);
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey);
$this->apiKeyService->expects($this->never())->method('disableByName');
$this->commandTester->execute([
'apiKey' => $apiKey,
$exitCode = $this->commandTester->execute([
'keyOrName' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
#[Test]
public function errorIsReturnedIfServiceThrowsException(): void
public function providedApiKeyIsDisabledByName(): void
{
$name = 'the key to delete';
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
$this->apiKeyService->expects($this->never())->method('disableByKey');
$exitCode = $this->commandTester->execute([
'keyOrName' => $name,
'--by-name' => true,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
#[Test]
public function errorIsReturnedIfDisableByKeyThrowsException(): void
{
$apiKey = 'abcd1234';
$expectedMessage = 'API key "abcd1234" does not exist.';
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException(
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException(
new InvalidArgumentException($expectedMessage),
);
$this->apiKeyService->expects($this->never())->method('disableByName');
$this->commandTester->execute([
'apiKey' => $apiKey,
$exitCode = $this->commandTester->execute([
'keyOrName' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
}
#[Test]
public function errorIsReturnedIfDisableByNameThrowsException(): void
{
$name = 'the key to delete';
$expectedMessage = 'API key "the key to delete" does not exist.';
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException(
new InvalidArgumentException($expectedMessage),
);
$this->apiKeyService->expects($this->never())->method('disableByKey');
$exitCode = $this->commandTester->execute([
'keyOrName' => $name,
'--by-name' => true,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
}
#[Test]
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
{
$this->apiKeyService->expects($this->never())->method('disableByName');
$this->apiKeyService->expects($this->never())->method('disableByKey');
$this->apiKeyService->expects($this->never())->method('listKeys');
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
}
#[Test]
public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void
{
$name = 'the key to delete';
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
]);
$this->apiKeyService->expects($this->never())->method('disableByKey');
$this->commandTester->setInputs([$name]);
$exitCode = $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
}

View File

@@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@@ -36,7 +37,7 @@ class GenerateKeyCommandTest extends TestCase
public function noExpirationDateIsDefinedIfNotProvided(): void
{
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->callback(fn (ApiKeyMeta $meta) => $meta->name === null && $meta->expirationDate === null),
$this->callback(fn (ApiKeyMeta $meta) => $meta->expirationDate === null),
)->willReturn(ApiKey::create());
$this->commandTester->execute([]);
@@ -64,8 +65,10 @@ class GenerateKeyCommandTest extends TestCase
$this->callback(fn (ApiKeyMeta $meta) => $meta->name === 'Alice'),
)->willReturn(ApiKey::create());
$this->commandTester->execute([
$exitCode = $this->commandTester->execute([
'--name' => 'Alice',
]);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
}

View File

@@ -27,8 +27,11 @@ class InitialApiKeyCommandTest extends TestCase
}
#[Test, DataProvider('provideParams')]
public function initialKeyIsCreatedWithProvidedValue(?ApiKey $result, bool $verbose, string $expectedOutput): void
{
public function initialKeyIsCreatedWithProvidedValue(
ApiKey|null $result,
bool $verbose,
string $expectedOutput,
): void {
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
$this->commandTester->execute(

View File

@@ -52,15 +52,15 @@ class ListKeysCommandTest extends TestCase
],
false,
<<<OUTPUT
+--------------------------------------+------+------------+---------------------------+-------+
| Key | Name | Is enabled | Expiration date | Roles |
+--------------------------------------+------+------------+---------------------------+-------+
| {$apiKey1} | - | --- | - | Admin |
+--------------------------------------+------+------------+---------------------------+-------+
| {$apiKey2} | - | --- | 2020-01-01T00:00:00+00:00 | Admin |
+--------------------------------------+------+------------+---------------------------+-------+
| {$apiKey3} | - | +++ | - | Admin |
+--------------------------------------+------+------------+---------------------------+-------+
+--------------------------------------+------------+---------------------------+-------+
| Name | Is enabled | Expiration date | Roles |
+--------------------------------------+------------+---------------------------+-------+
| {$apiKey1->name} | --- | - | Admin |
+--------------------------------------+------------+---------------------------+-------+
| {$apiKey2->name} | --- | 2020-01-01T00:00:00+00:00 | Admin |
+--------------------------------------+------------+---------------------------+-------+
| {$apiKey3->name} | +++ | - | Admin |
+--------------------------------------+------------+---------------------------+-------+
OUTPUT,
];
@@ -68,13 +68,13 @@ class ListKeysCommandTest extends TestCase
[$apiKey1 = ApiKey::create()->disable(), $apiKey2 = ApiKey::create()],
true,
<<<OUTPUT
+--------------------------------------+------+-----------------+-------+
| Key | Name | Expiration date | Roles |
+--------------------------------------+------+-----------------+-------+
| {$apiKey1} | - | - | Admin |
+--------------------------------------+------+-----------------+-------+
| {$apiKey2} | - | - | Admin |
+--------------------------------------+------+-----------------+-------+
+--------------------------------------+-----------------+-------+
| Name | Expiration date | Roles |
+--------------------------------------+-----------------+-------+
| {$apiKey1->name} | - | Admin |
+--------------------------------------+-----------------+-------+
| {$apiKey2->name} | - | Admin |
+--------------------------------------+-----------------+-------+
OUTPUT,
];
@@ -94,45 +94,45 @@ class ListKeysCommandTest extends TestCase
],
true,
<<<OUTPUT
+--------------------------------------+------+-----------------+--------------------------+
| Key | Name | Expiration date | Roles |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey1} | - | - | Admin |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey2} | - | - | Author only |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey3} | - | - | Domain only: example.com |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey4} | - | - | Admin |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey5} | - | - | Author only |
| | | | Domain only: example.com |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey6} | - | - | Admin |
+--------------------------------------+------+-----------------+--------------------------+
+--------------------------------------+-----------------+--------------------------+
| Name | Expiration date | Roles |
+--------------------------------------+-----------------+--------------------------+
| {$apiKey1->name} | - | Admin |
+--------------------------------------+-----------------+--------------------------+
| {$apiKey2->name} | - | Author only |
+--------------------------------------+-----------------+--------------------------+
| {$apiKey3->name} | - | Domain only: example.com |
+--------------------------------------+-----------------+--------------------------+
| {$apiKey4->name} | - | Admin |
+--------------------------------------+-----------------+--------------------------+
| {$apiKey5->name} | - | Author only |
| | | Domain only: example.com |
+--------------------------------------+-----------------+--------------------------+
| {$apiKey6->name} | - | Admin |
+--------------------------------------+-----------------+--------------------------+
OUTPUT,
];
yield 'with names' => [
[
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')),
$apiKey3 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: '')),
$apiKey4 = ApiKey::create(),
],
true,
<<<OUTPUT
+--------------------------------------+---------------+-----------------+-------+
| Key | Name | Expiration date | Roles |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey1} | Alice | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey2} | Alice and Bob | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey3} | | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey4} | - | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
+--------------------------------------+-----------------+-------+
| Name | Expiration date | Roles |
+--------------------------------------+-----------------+-------+
| Alice | - | Admin |
+--------------------------------------+-----------------+-------+
| Alice and Bob | - | Admin |
+--------------------------------------+-----------------+-------+
| {$apiKey3->name} | - | Admin |
+--------------------------------------+-----------------+-------+
| {$apiKey4->name} | - | Admin |
+--------------------------------------+-----------------+-------+
OUTPUT,
];

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\RenameApiKeyCommand;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class RenameApiKeyCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = CliTestUtils::testerForCommand(new RenameApiKeyCommand($this->apiKeyService));
}
#[Test]
public function oldNameIsRequestedIfNotProvided(): void
{
$oldName = 'old name';
$newName = 'new name';
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $oldName)),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
]);
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
Renaming::fromNames($oldName, $newName),
);
$this->commandTester->setInputs([$oldName]);
$this->commandTester->execute([
'newName' => $newName,
]);
}
#[Test]
public function newNameIsRequestedIfNotProvided(): void
{
$oldName = 'old name';
$newName = 'new name';
$this->apiKeyService->expects($this->never())->method('listKeys');
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
Renaming::fromNames($oldName, $newName),
);
$this->commandTester->setInputs([$newName]);
$this->commandTester->execute([
'oldName' => $oldName,
]);
}
#[Test]
public function apiIsRenamedWithProvidedNames(): void
{
$oldName = 'old name';
$newName = 'new name';
$this->apiKeyService->expects($this->never())->method('listKeys');
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
Renaming::fromNames($oldName, $newName),
);
$this->commandTester->execute([
'oldName' => $oldName,
'newName' => $newName,
]);
}
}

View File

@@ -31,7 +31,7 @@ class DomainRedirectsCommandTest extends TestCase
}
#[Test, DataProvider('provideDomains')]
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(Domain|null $domain): void
{
$domainAuthority = 'my-domain.com';
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(

View File

@@ -40,7 +40,7 @@ class GetDomainVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$domain = 's.test';

View File

@@ -104,7 +104,7 @@ class CreateShortUrlCommandTest extends TestCase
}
#[Test, DataProvider('provideDomains')]
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
public function properlyProcessesProvidedDomain(array $input, string|null $expectedDomain): void
{
$this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) {
@@ -128,8 +128,10 @@ class CreateShortUrlCommandTest extends TestCase
}
#[Test, DataProvider('provideFlags')]
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void
{
public function urlValidationHasExpectedValueBasedOnProvidedFlags(
array $options,
bool|null $expectedCrawlable,
): void {
$shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) {

View File

@@ -93,7 +93,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
#[Test]
public function outputIsProperlyGenerated(): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate(
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$shortCode = 'abc123';

View File

@@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
@@ -25,7 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function count;
use function explode;
class ListShortUrlsCommandTest extends TestCase
@@ -48,7 +47,7 @@ class ListShortUrlsCommandTest extends TestCase
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 50; $i++) {
$data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
$data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
}
$this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters()
@@ -70,11 +69,11 @@ class ListShortUrlsCommandTest extends TestCase
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 30; $i++) {
$data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
$data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
}
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::emptyInstance(),
ShortUrlsParams::empty(),
)->willReturn(new Paginator(new ArrayAdapter($data)));
$this->commandTester->setInputs(['n']);
@@ -105,105 +104,111 @@ class ListShortUrlsCommandTest extends TestCase
#[Test, DataProvider('provideOptionalFlags')]
public function provideOptionalFlagsMakesNewColumnsToBeIncluded(
array $input,
array $expectedContents,
array $notExpectedContents,
ApiKey $apiKey,
string $expectedOutput,
ShortUrl $shortUrl,
): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::emptyInstance(),
ShortUrlsParams::empty(),
)->willReturn(new Paginator(new ArrayAdapter([
ShortUrlWithVisitsSummary::fromShortUrl(
ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo.com',
'tags' => ['foo', 'bar', 'baz'],
'apiKey' => $apiKey,
])),
),
ShortUrlWithDeps::fromShortUrl($shortUrl),
])));
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($input);
$output = $this->commandTester->getDisplay();
if (count($expectedContents) === 0 && count($notExpectedContents) === 0) {
self::fail('No expectations were run');
}
foreach ($expectedContents as $column) {
self::assertStringContainsString($column, $output);
}
foreach ($notExpectedContents as $column) {
self::assertStringNotContainsString($column, $output);
}
self::assertStringContainsString($expectedOutput, $output);
}
public static function provideOptionalFlags(): iterable
{
$apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key'));
$key = $apiKey->toString();
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo.com',
'tags' => ['foo', 'bar', 'baz'],
'apiKey' => ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')),
]));
$shortCode = $shortUrl->getShortCode();
$created = $shortUrl->dateCreated()->toAtomString();
// phpcs:disable Generic.Files.LineLength
yield 'tags only' => [
['--show-tags' => true],
['| Tags ', '| foo, bar, baz'],
['| API Key ', '| API Key Name |', $key, '| my api key', '| Domain', '| DEFAULT'],
$apiKey,
<<<OUTPUT
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags |
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz |
+------------+-------+-------------+-------------- Page 1 of 1 ------------------+--------------+---------------+
OUTPUT,
$shortUrl,
];
yield 'domain only' => [
['--show-domain' => true],
['| Domain', '| DEFAULT'],
['| Tags ', '| foo, bar, baz', '| API Key ', '| API Key Name |', $key, '| my api key'],
$apiKey,
<<<OUTPUT
+------------+-------+-------------+-----------------+---------------------------+--------------+---------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Domain |
+------------+-------+-------------+-----------------+---------------------------+--------------+---------+
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | DEFAULT |
+------------+-------+-------------+----------- Page 1 of 1 ---------------------+--------------+---------+
OUTPUT,
$shortUrl,
];
yield 'api key only' => [
['--show-api-key' => true],
['| API Key ', $key],
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key', '| Domain', '| DEFAULT'],
$apiKey,
];
yield 'api key name only' => [
['--show-api-key-name' => true],
['| API Key Name |', '| my api key'],
['| Tags ', '| foo, bar, baz', '| API Key ', $key],
$apiKey,
<<<OUTPUT
+------------+-------+-------------+-----------------+---------------------------+--------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count | API Key Name |
+------------+-------+-------------+-----------------+---------------------------+--------------+--------------+
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | my api key |
+------------+-------+-------------+------------- Page 1 of 1 -------------------+--------------+--------------+
OUTPUT,
$shortUrl,
];
yield 'tags and api key' => [
['--show-tags' => true, '--show-api-key' => true],
['| API Key ', '| Tags ', '| foo, bar, baz', $key],
['| API Key Name |', '| my api key'],
$apiKey,
<<<OUTPUT
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags | API Key Name |
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+--------------+
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz | my api key |
+------------+-------+-------------+-----------------+--- Page 1 of 1 -----------+--------------+---------------+--------------+
OUTPUT,
$shortUrl,
];
yield 'tags and domain' => [
['--show-tags' => true, '--show-domain' => true],
['| Tags ', '| foo, bar, baz', '| Domain', '| DEFAULT'],
['| API Key Name |', '| my api key'],
$apiKey,
<<<OUTPUT
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags | Domain |
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz | DEFAULT |
+------------+-------+-------------+-----------------+- Page 1 of 1 -------------+--------------+---------------+---------+
OUTPUT,
$shortUrl,
];
yield 'all' => [
['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true, '--show-api-key-name' => true],
[
'| API Key ',
'| Tags ',
'| API Key Name |',
'| foo, bar, baz',
$key,
'| my api key',
'| Domain',
'| DEFAULT',
],
[],
$apiKey,
['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true],
<<<OUTPUT
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags | Domain | API Key Name |
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+--------------+
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz | DEFAULT | my api key |
+------------+-------+-------------+-----------------+-------- Page 1 of 1 ------+--------------+---------------+---------+--------------+
OUTPUT,
$shortUrl,
];
// phpcs:enable
}
#[Test, DataProvider('provideArgs')]
public function serviceIsInvokedWithProvidedArgs(
array $commandArgs,
?int $page,
?string $searchTerm,
int|null $page,
string|null $searchTerm,
array $tags,
string $tagsMode,
?string $startDate = null,
?string $endDate = null,
string|null $startDate = null,
string|null $endDate = null,
): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
'page' => $page,
@@ -260,7 +265,7 @@ class ListShortUrlsCommandTest extends TestCase
}
#[Test, DataProvider('provideOrderBy')]
public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void
public function orderByIsProperlyComputed(array $commandArgs, string|null $expectedOrderBy): void
{
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
'orderBy' => $expectedOrderBy,

View File

@@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$tag = 'abc123';

View File

@@ -9,8 +9,8 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
@@ -32,7 +32,7 @@ class RenameTagCommandTest extends TestCase
$oldName = 'foo';
$newName = 'bar';
$this->tagService->expects($this->once())->method('renameTag')->with(
TagRenaming::fromNames($oldName, $newName),
Renaming::fromNames($oldName, $newName),
)->willThrowException(TagNotFoundException::fromTag('foo'));
$this->commandTester->execute([
@@ -50,7 +50,7 @@ class RenameTagCommandTest extends TestCase
$oldName = 'foo';
$newName = 'bar';
$this->tagService->expects($this->once())->method('renameTag')->with(
TagRenaming::fromNames($oldName, $newName),
Renaming::fromNames($oldName, $newName),
)->willReturn(new Tag($newName));
$this->commandTester->execute([

View File

@@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn(

View File

@@ -37,7 +37,7 @@ class GetOrphanVisitsCommandTest extends TestCase
#[TestWith([['--type' => OrphanVisitType::BASE_URL->value], true])]
public function outputIsProperlyGenerated(array $args, bool $includesType): void
{
$visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate(
$visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback(

View File

@@ -63,8 +63,8 @@ class LocateVisitsCommandTest extends TestCase
bool $expectWarningPrint,
array $args,
): void {
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::empty());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
@@ -107,7 +107,7 @@ class LocateVisitsCommandTest extends TestCase
#[Test, DataProvider('provideIgnoredAddresses')]
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
@@ -134,7 +134,7 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function errorWhileLocatingIpIsDisplayed(): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);

View File

@@ -15,7 +15,7 @@ use Throwable;
class GeolocationDbUpdateFailedExceptionTest extends TestCase
{
#[Test, DataProvider('providePrev')]
public function withOlderDbBuildsException(?Throwable $prev): void
public function withOlderDbBuildsException(Throwable|null $prev): void
{
$e = GeolocationDbUpdateFailedException::withOlderDb($prev);
@@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
}
#[Test, DataProvider('providePrev')]
public function withoutOlderDbBuildsException(?Throwable $prev): void
public function withoutOlderDbBuildsException(Throwable|null $prev): void
{
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);

View File

@@ -41,22 +41,24 @@ class GeolocationDbUpdaterTest extends TestCase
#[Test]
public function properResultIsReturnedWhenLicenseIsMissing(): void
{
$mustBeUpdated = fn () => self::assertTrue(true);
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException(
new MissingLicenseException(''),
);
$this->geoLiteDbReader->expects($this->never())->method('metadata');
$result = $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
$isCalled = false;
$result = $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
$isCalled = true;
});
self::assertTrue($isCalled);
self::assertEquals(GeolocationResult::LICENSE_MISSING, $result);
}
#[Test]
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{
$mustBeUpdated = fn () => self::assertTrue(true);
$prev = new DbUpdateException('');
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
@@ -65,14 +67,17 @@ class GeolocationDbUpdaterTest extends TestCase
)->willThrowException($prev);
$this->geoLiteDbReader->expects($this->never())->method('metadata');
$isCalled = false;
try {
$this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
$this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
$isCalled = true;
});
self::fail();
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
self::assertSame($prev, $e->getPrevious());
self::assertFalse($e->olderDbExists());
self::assertTrue($isCalled);
}
}
@@ -92,7 +97,6 @@ class GeolocationDbUpdaterTest extends TestCase
$this->geolocationDbUpdater()->checkDbUpdate();
self::fail();
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
self::assertSame($prev, $e->getPrevious());
self::assertTrue($e->olderDbExists());
@@ -180,7 +184,7 @@ class GeolocationDbUpdaterTest extends TestCase
yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)];
}
private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater
private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater
{
$locker = $this->createMock(Lock\LockFactory::class);
$locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock);

View File

@@ -56,7 +56,7 @@ class RedirectRuleHandlerTest extends TestCase
#[Test, DataProvider('provideExitActions')]
public function commentIsDisplayedWhenRulesListIsEmpty(
RedirectRuleHandlerAction $action,
?array $expectedResult,
array|null $expectedResult,
): void {
$this->io->expects($this->once())->method('choice')->willReturn($action->value);
$this->io->expects($this->once())->method('newLine');
@@ -117,6 +117,8 @@ class RedirectRuleHandlerTest extends TestCase
'Query param name?' => 'foo',
'Query param value?' => 'bar',
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
'Country code to match?' => 'FR',
'City name to match?' => 'Los angeles',
default => '',
},
);
@@ -165,6 +167,14 @@ class RedirectRuleHandlerTest extends TestCase
true,
];
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
yield 'Geolocation country code' => [
RedirectConditionType::GEOLOCATION_COUNTRY_CODE,
[RedirectCondition::forGeolocationCountryCode('FR')],
];
yield 'Geolocation city name' => [
RedirectConditionType::GEOLOCATION_CITY_NAME,
[RedirectCondition::forGeolocationCityName('Los angeles')],
];
}
#[Test]

View File

@@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Lock;
@@ -50,6 +51,10 @@ return [
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class,
ShortUrl\Repository\ShortUrlRepository::class => [
EntityRepositoryFactory::class,
ShortUrl\Entity\ShortUrl::class,
],
ShortUrl\Repository\ShortUrlListRepository::class => [
EntityRepositoryFactory::class,
ShortUrl\Entity\ShortUrl::class,
@@ -64,8 +69,10 @@ return [
],
Tag\TagService::class => ConfigAbstractFactory::class,
Tag\Repository\TagRepository::class => [EntityRepositoryFactory::class, Tag\Entity\Tag::class],
Domain\DomainService::class => ConfigAbstractFactory::class,
Domain\Repository\DomainRepository::class => [EntityRepositoryFactory::class, Domain\Entity\Domain::class],
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
Visit\RequestTracker::class => ConfigAbstractFactory::class,
@@ -96,6 +103,8 @@ return [
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
Geolocation\Middleware\IpGeolocationMiddleware::class => ConfigAbstractFactory::class,
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
Crawling\CrawlingHelper::class => ConfigAbstractFactory::class,
@@ -132,6 +141,7 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
ShortUrl\Helper\ShortCodeUniquenessHelper::class,
EventDispatcherInterface::class,
ShortUrl\Repository\ShortUrlRepository::class,
],
Visit\VisitsTracker::class => [
'em',
@@ -153,20 +163,30 @@ return [
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],
Tag\TagService::class => ['em', Tag\Repository\TagRepository::class],
ShortUrl\DeleteShortUrlService::class => [
'em',
Config\Options\DeleteShortUrlsOptions::class,
ShortUrl\ShortUrlResolver::class,
ShortUrl\Repository\ExpiredShortUrlsRepository::class,
],
ShortUrl\ShortUrlResolver::class => ['em', Config\Options\UrlShortenerOptions::class],
ShortUrl\ShortUrlResolver::class => [
ShortUrl\Repository\ShortUrlRepository::class,
Config\Options\UrlShortenerOptions::class,
],
ShortUrl\ShortUrlVisitsDeleter::class => [
Visit\Repository\VisitDeleterRepository::class,
ShortUrl\ShortUrlResolver::class,
],
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
Domain\DomainService::class => ['em', Config\Options\UrlShortenerOptions::class],
ShortUrl\Helper\ShortCodeUniquenessHelper::class => [
ShortUrl\Repository\ShortUrlRepository::class,
Config\Options\UrlShortenerOptions::class,
],
Domain\DomainService::class => [
'em',
Config\Options\UrlShortenerOptions::class,
Domain\Repository\DomainRepository::class,
],
Util\DoctrineBatchHelper::class => ['em'],
Util\RedirectResponseHelper::class => [Config\Options\RedirectOptions::class],
@@ -220,6 +240,13 @@ return [
EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class],
Geolocation\Middleware\IpGeolocationMiddleware::class => [
IpLocationResolverInterface::class,
DbUpdater::class,
'Logger_Shlink',
Config\Options\TrackingOptions::class,
],
Importer\ImportedLinksProcessor::class => [
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,

View File

@@ -110,4 +110,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->columnName('forward_query')
->option('default', true)
->build();
$builder->createOneToMany('redirectRules', RedirectRule\Entity\ShortUrlRedirectRule::class)
->mappedBy('shortUrl')
->fetchExtraLazy()
->build();
};

View File

@@ -75,4 +75,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->columnName('potential_bot')
->option('default', false)
->build();
fieldWithUtf8Charset($builder->createField('redirectUrl', Types::STRING), $emConfig)
->columnName('redirect_url')
->length(Visitor::REDIRECT_URL_MAX_LENGTH)
->nullable()
->build();
};

View File

@@ -15,23 +15,18 @@ use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper;
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return (static function (): array {
$regularEvents = [
EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\LocateVisit::class,
],
EventDispatcher\Event\GeoLiteDbCreated::class => [
EventDispatcher\LocateUnlocatedVisits::class,
],
];
$asyncEvents = [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
@@ -46,9 +41,9 @@ return (static function (): array {
// Send visits to matomo asynchronously if the runtime allows it
if (runningInRoadRunner()) {
$asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class;
$asyncEvents[EventDispatcher\Event\UrlVisited::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class;
} else {
$regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class];
$regularEvents[EventDispatcher\Event\UrlVisited::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class];
}
return [
@@ -60,7 +55,6 @@ return (static function (): array {
'dependencies' => [
'factories' => [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class,
EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
@@ -104,13 +98,6 @@ return (static function (): array {
],
ConfigAbstractFactory::class => [
EventDispatcher\LocateVisit::class => [
IpLocationResolverInterface::class,
'em',
'Logger_Shlink',
DbUpdater::class,
EventDispatcherInterface::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
MercureHubPublishingHelper::class,

View File

@@ -15,9 +15,9 @@ 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;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use function array_keys;
use function array_map;
@@ -37,6 +37,8 @@ use function strtolower;
use function trim;
use function ucfirst;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
{
static $nanoIdClient;
@@ -50,7 +52,7 @@ function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode:
return $nanoIdClient->formattedId($alphabet, $length);
}
function parseDateFromQuery(array $query, string $dateName): ?Chronos
function parseDateFromQuery(array $query, string $dateName): Chronos|null
{
return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
}
@@ -63,7 +65,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
return buildDateRange($startDate, $endDate);
}
function dateRangeToHumanFriendly(?DateRange $dateRange): string
function dateRangeToHumanFriendly(DateRange|null $dateRange): string
{
$startDate = $dateRange?->startDate;
$endDate = $dateRange?->endDate;
@@ -83,7 +85,7 @@ function dateRangeToHumanFriendly(?DateRange $dateRange): string
/**
* @return ($date is null ? null : Chronos)
*/
function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): Chronos|null
{
$parsedDate = match (true) {
$date === null || $date instanceof Chronos => $date,
@@ -109,7 +111,7 @@ function normalizeLocale(string $locale): string
* minimum quality
*
* @param non-empty-string $acceptLanguage
* @return iterable<string>;
* @return iterable<string>
*/
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
{
@@ -148,7 +150,7 @@ function splitLocale(string $locale): array
/**
* @param InputFilter<mixed> $inputFilter
*/
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): int|null
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
@@ -157,7 +159,7 @@ function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldNa
/**
* @param InputFilter<mixed> $inputFilter
*/
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): bool|null
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (bool) $value : null;
@@ -276,7 +278,7 @@ function enumToString(string $enum): string
* 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
function splitByComma(string|null $value): array
{
if ($value === null || trim($value) === '') {
return [];
@@ -285,7 +287,17 @@ function splitByComma(?string $value): array
return array_map(trim(...), explode(',', $value));
}
function ipAddressFromRequest(ServerRequestInterface $request): ?string
function ipAddressFromRequest(ServerRequestInterface $request): string|null
{
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
return $request->getAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE);
}
function geolocationFromRequest(ServerRequestInterface $request): Location|null
{
$geolocation = $request->getAttribute(Location::class);
if ($geolocation !== null && ! $geolocation instanceof Location) {
// TODO Throw exception
}
return $geolocation;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* In preparation to start hashing API keys, move all plain-text keys to the `name` column for all keys without name,
* and append it to the name for all keys which already have a name.
*/
final class Version20241105094747 extends AbstractMigration
{
public function up(Schema $schema): void
{
$keyColumnName = $this->connection->quoteIdentifier('key');
// Append key to the name for all API keys that already have a name
$qb = $this->connection->createQueryBuilder();
$qb->update('api_keys')
->set('name', 'CONCAT(name, ' . $this->connection->quote(' - ') . ', ' . $keyColumnName . ')')
->where($qb->expr()->isNotNull('name'));
$qb->executeStatement();
// Set plain key as name for all API keys without a name
$qb = $this->connection->createQueryBuilder();
$qb->update('api_keys')
->set('name', $keyColumnName)
->where($qb->expr()->isNull('name'));
$qb->executeStatement();
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use function hash;
/**
* Hash API keys as SHA256
*/
final class Version20241105215309 extends AbstractMigration
{
public function up(Schema $schema): void
{
$keyColumnName = $this->connection->quoteIdentifier('key');
$qb = $this->connection->createQueryBuilder();
$qb->select($keyColumnName)
->from('api_keys');
$result = $qb->executeQuery();
$updateQb = $this->connection->createQueryBuilder();
$updateQb
->update('api_keys')
->set($keyColumnName, ':encryptedKey')
->where($updateQb->expr()->eq($keyColumnName, ':plainTextKey'));
while ($key = $result->fetchOne()) {
$updateQb->setParameters([
'encryptedKey' => hash('sha256', $key),
'plainTextKey' => $key,
])->executeStatement();
}
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20241124112257 extends AbstractMigration
{
private const COLUMN_NAME = 'redirect_url';
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf($visits->hasColumn(self::COLUMN_NAME));
$visits->addColumn(self::COLUMN_NAME, Types::STRING, [
'length' => 2048,
'notnull' => false,
'default' => null,
]);
}
public function down(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf(! $visits->hasColumn(self::COLUMN_NAME));
$visits->dropColumn(self::COLUMN_NAME);
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View File

@@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
public function __construct(
@@ -30,9 +32,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
try {
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
$response = $this->createSuccessResp($shortUrl, $request);
$this->requestTracker->trackIfApplicable($shortUrl, $request->withAttribute(
REDIRECT_URL_REQUEST_ATTRIBUTE,
$response->hasHeader('Location') ? $response->getHeaderLine('Location') : null,
));
return $this->createSuccessResp($shortUrl, $request);
return $response;
} catch (ShortUrlNotFoundException) {
return $this->createErrorResp($request, $handler);
}

View File

@@ -123,7 +123,7 @@ final class QrCodeParams
return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR);
}
private static function parseHexColor(string $hexColor, ?string $fallback): Color
private static function parseHexColor(string $hexColor, string|null $fallback): Color
{
$hexColor = ltrim($hexColor, '#');
if (! ctype_xdigit($hexColor) && $fallback !== null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@@ -13,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
class RedirectAction extends AbstractTrackingAction
{
public function __construct(
ShortUrlResolverInterface $urlResolver,

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface
{
public function invalidShortUrlRedirect(): ?string
public function invalidShortUrlRedirect(): string|null
{
return null;
}
@@ -16,7 +16,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa
return false;
}
public function regular404Redirect(): ?string
public function regular404Redirect(): string|null
{
return null;
}
@@ -26,7 +26,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa
return false;
}
public function baseUrlRedirect(): ?string
public function baseUrlRedirect(): string|null
{
return null;
}

View File

@@ -6,15 +6,15 @@ namespace Shlinkio\Shlink\Core\Config;
interface NotFoundRedirectConfigInterface
{
public function invalidShortUrlRedirect(): ?string;
public function invalidShortUrlRedirect(): string|null;
public function hasInvalidShortUrlRedirect(): bool;
public function regular404Redirect(): ?string;
public function regular404Redirect(): string|null;
public function hasRegular404Redirect(): bool;
public function baseUrlRedirect(): ?string;
public function baseUrlRedirect(): string|null;
public function hasBaseUrlRedirect(): bool;
}

View File

@@ -30,7 +30,7 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
NotFoundType $notFoundType,
NotFoundRedirectConfigInterface $config,
UriInterface $currentUri,
): ?ResponseInterface {
): ResponseInterface|null {
$urlToRedirectTo = match (true) {
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(),
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(),

View File

@@ -14,5 +14,5 @@ interface NotFoundRedirectResolverInterface
NotFoundType $notFoundType,
NotFoundRedirectConfigInterface $config,
UriInterface $currentUri,
): ?ResponseInterface;
): ResponseInterface|null;
}

View File

@@ -9,16 +9,16 @@ use JsonSerializable;
final class NotFoundRedirects implements JsonSerializable
{
private function __construct(
public readonly ?string $baseUrlRedirect,
public readonly ?string $regular404Redirect,
public readonly ?string $invalidShortUrlRedirect,
public readonly string|null $baseUrlRedirect,
public readonly string|null $regular404Redirect,
public readonly string|null $invalidShortUrlRedirect,
) {
}
public static function withRedirects(
?string $baseUrlRedirect,
?string $regular404Redirect = null,
?string $invalidShortUrlRedirect = null,
string|null $baseUrlRedirect,
string|null $regular404Redirect = null,
string|null $invalidShortUrlRedirect = null,
): self {
return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect);
}

View File

@@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface
{
public function __construct(
public ?string $invalidShortUrl = null,
public ?string $regular404 = null,
public ?string $baseUrl = null,
public string|null $invalidShortUrl = null,
public string|null $regular404 = null,
public string|null $baseUrl = null,
) {
}
@@ -25,7 +25,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn
);
}
public function invalidShortUrlRedirect(): ?string
public function invalidShortUrlRedirect(): string|null
{
return $this->invalidShortUrl;
}
@@ -35,7 +35,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn
return $this->invalidShortUrl !== null;
}
public function regular404Redirect(): ?string
public function regular404Redirect(): string|null
{
return $this->regular404;
}
@@ -45,7 +45,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn
return $this->regular404 !== null;
}
public function baseUrlRedirect(): ?string
public function baseUrlRedirect(): string|null
{
return $this->baseUrl;
}

View File

@@ -26,7 +26,7 @@ final readonly class QrCodeOptions
public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
public string $color = DEFAULT_QR_CODE_COLOR,
public string $bgColor = DEFAULT_QR_CODE_BG_COLOR,
public ?string $logoUrl = null,
public string|null $logoUrl = null,
) {
}

View File

@@ -22,7 +22,7 @@ final readonly class TrackingOptions
public bool $trackOrphanVisits = true,
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence over
// other options
public ?string $disableTrackParam = null,
public string|null $disableTrackParam = null,
// If true, visits will not be tracked at all
public bool $disableTracking = false,
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
@@ -59,4 +59,12 @@ final readonly class TrackingOptions
{
return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query);
}
/**
* If IP address tracking is disabled, or tracking is disabled all together, then geolocation is not relevant
*/
public function isGeolocationRelevant(): bool
{
return ! $this->disableTracking && ! $this->disableIpTracking;
}
}

View File

@@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Crawling;
use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface;
class CrawlingHelper implements CrawlingHelperInterface
readonly class CrawlingHelper implements CrawlingHelperInterface
{
public function __construct(private readonly CrawlableShortCodesQueryInterface $query)
public function __construct(private CrawlableShortCodesQueryInterface $query)
{
}

View File

@@ -19,14 +19,17 @@ use function array_map;
readonly class DomainService implements DomainServiceInterface
{
public function __construct(private EntityManagerInterface $em, private UrlShortenerOptions $urlShortenerOptions)
{
public function __construct(
private EntityManagerInterface $em,
private UrlShortenerOptions $urlShortenerOptions,
private DomainRepositoryInterface $repo,
) {
}
/**
* @return DomainItem[]
*/
public function listDomains(?ApiKey $apiKey = null): array
public function listDomains(ApiKey|null $apiKey = null): array
{
[$default, $domains] = $this->defaultDomainAndRest($apiKey);
$mappedDomains = array_map(fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain), $domains);
@@ -47,11 +50,9 @@ readonly class DomainService implements DomainServiceInterface
/**
* @return array{Domain|null, Domain[]}
*/
private function defaultDomainAndRest(?ApiKey $apiKey): array
private function defaultDomainAndRest(ApiKey|null $apiKey): array
{
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
$allDomains = $repo->findDomains($apiKey);
$allDomains = $this->repo->findDomains($apiKey);
$defaultDomain = null;
$restOfDomains = [];
@@ -71,7 +72,6 @@ readonly class DomainService implements DomainServiceInterface
*/
public function getDomain(string $domainId): Domain
{
/** @var Domain|null $domain */
$domain = $this->em->find(Domain::class, $domainId);
if ($domain === null) {
throw DomainNotFoundException::fromId($domainId);
@@ -80,15 +80,15 @@ readonly class DomainService implements DomainServiceInterface
return $domain;
}
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null
{
return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey);
return $this->repo->findOneByAuthority($authority, $apiKey);
}
/**
* @throws DomainNotFoundException
*/
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain
public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain
{
$domain = $this->getPersistedDomain($authority, $apiKey);
$this->em->flush();
@@ -102,7 +102,7 @@ readonly class DomainService implements DomainServiceInterface
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null,
ApiKey|null $apiKey = null,
): Domain {
$domain = $this->getPersistedDomain($authority, $apiKey);
$domain->configureNotFoundRedirects($notFoundRedirects);
@@ -115,7 +115,7 @@ readonly class DomainService implements DomainServiceInterface
/**
* @throws DomainNotFoundException
*/
private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain
private function getPersistedDomain(string $authority, ApiKey|null $apiKey): Domain
{
$domain = $this->findByAuthority($authority, $apiKey);
if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {

View File

@@ -15,7 +15,7 @@ interface DomainServiceInterface
/**
* @return DomainItem[]
*/
public function listDomains(?ApiKey $apiKey = null): array;
public function listDomains(ApiKey|null $apiKey = null): array;
/**
* @throws DomainNotFoundException
@@ -25,9 +25,9 @@ interface DomainServiceInterface
/**
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
*/
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain;
public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain;
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null;
/**
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
@@ -35,6 +35,6 @@ interface DomainServiceInterface
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null,
ApiKey|null $apiKey = null,
): Domain;
}

View File

@@ -11,11 +11,13 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface
{
public const DEFAULT_AUTHORITY = 'DEFAULT';
private function __construct(
public readonly string $authority,
private ?string $baseUrlRedirect = null,
private ?string $regular404Redirect = null,
private ?string $invalidShortUrlRedirect = null,
private string|null $baseUrlRedirect = null,
private string|null $regular404Redirect = null,
private string|null $invalidShortUrlRedirect = null,
) {
}
@@ -29,7 +31,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
return $this->authority;
}
public function invalidShortUrlRedirect(): ?string
public function invalidShortUrlRedirect(): string|null
{
return $this->invalidShortUrlRedirect;
}
@@ -39,7 +41,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
return $this->invalidShortUrlRedirect !== null;
}
public function regular404Redirect(): ?string
public function regular404Redirect(): string|null
{
return $this->regular404Redirect;
}
@@ -49,7 +51,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
return $this->regular404Redirect !== null;
}
public function baseUrlRedirect(): ?string
public function baseUrlRedirect(): string|null
{
return $this->baseUrlRedirect;
}

View File

@@ -9,12 +9,12 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
final class DomainItem implements JsonSerializable
final readonly class DomainItem implements JsonSerializable
{
private function __construct(
private readonly string $authority,
public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig,
public readonly bool $isDefault,
private string $authority,
public NotFoundRedirectConfigInterface $notFoundRedirectConfig,
public bool $isDefault,
) {
}

View File

@@ -20,7 +20,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
/**
* @return Domain[]
*/
public function findDomains(?ApiKey $apiKey = null): array
public function findDomains(ApiKey|null $apiKey = null): array
{
$qb = $this->createQueryBuilder('d');
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
@@ -39,7 +39,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
return $qb->getQuery()->getResult();
}
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null
{
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
$qb->select('d');
@@ -47,7 +47,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
return $qb->getQuery()->getOneOrNullResult();
}
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool
public function domainExists(string $authority, ApiKey|null $apiKey = null): bool
{
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
$qb->select('COUNT(d.id)');
@@ -55,7 +55,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder
private function createDomainQueryBuilder(string $authority, ApiKey|null $apiKey): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Domain::class, 'd')
@@ -72,7 +72,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
return $qb;
}
private function determineExtraSpecs(?ApiKey $apiKey): iterable
private function determineExtraSpecs(ApiKey|null $apiKey): iterable
{
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
// ShortUrl is the root entity. Here, the Domain is the root entity.

View File

@@ -15,9 +15,9 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
/**
* @return Domain[]
*/
public function findDomains(?ApiKey $apiKey = null): array;
public function findDomains(ApiKey|null $apiKey = null): array;
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null;
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool;
public function domainExists(string $authority, ApiKey|null $apiKey = null): bool;
}

View File

@@ -10,7 +10,7 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification;
class IsDomain extends BaseSpecification
{
public function __construct(private string $domainId, ?string $context = null)
public function __construct(private string $domainId, string|null $context = null)
{
parent::__construct($context);
}

View File

@@ -11,9 +11,9 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use function rtrim;
class NotFoundType
readonly class NotFoundType
{
private function __construct(private readonly ?VisitType $type)
private function __construct(private VisitType|null $type)
{
}

View File

@@ -40,7 +40,7 @@ readonly class NotFoundRedirectHandler implements MiddlewareInterface
private function resolveDomainSpecificRedirect(
UriInterface $currentUri,
NotFoundType $notFoundType,
): ?ResponseInterface {
): ResponseInterface|null {
$domain = $this->domainService->findByAuthority($currentUri->getAuthority());
if ($domain === null) {
return null;

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