From 49c67abf0a6f78ed0fcdfe104b58911a3ad2d1bd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Nov 2025 12:53:14 +0100 Subject: [PATCH 01/71] Add missing entry in 4.6.0 changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a3a245..a7ae2865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is done via the `domain` query parameter in API endpoints, and via the `--domain` option in console commands. * [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5 +* [#2291](https://github.com/shlinkio/shlink/issues/2291) Add `api-key:delete` console command to delete API keys. ### Changed * [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable. From b4043be7fadf8327bcdcef9a490e3e4b782de623 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 7 Nov 2025 16:58:19 +0100 Subject: [PATCH 02/71] Drop support for QR code generation --- CHANGELOG.md | 17 + Dockerfile | 5 +- README.md | 3 +- composer.json | 1 - config/autoload/installer.global.php | 9 - config/autoload/routes.config.php | 8 - config/constants.php | 17 - docs/swagger/paths/{shortCode}_qr-code.json | 121 ------- docs/swagger/swagger.json | 3 - module/Core/config/dependencies.config.php | 8 - module/Core/src/Action/Model/QrCodeParams.php | 161 ---------- module/Core/src/Action/QrCodeAction.php | 74 ----- module/Core/src/Config/EnvVars.php | 35 -- .../Core/src/Config/Options/QrCodeOptions.php | 48 --- module/Core/test-api/Action/QrCodeTest.php | 28 -- module/Core/test/Action/QrCodeActionTest.php | 303 ------------------ 16 files changed, 20 insertions(+), 821 deletions(-) delete mode 100644 docs/swagger/paths/{shortCode}_qr-code.json delete mode 100644 module/Core/src/Action/Model/QrCodeParams.php delete mode 100644 module/Core/src/Action/QrCodeAction.php delete mode 100644 module/Core/src/Config/Options/QrCodeOptions.php delete mode 100644 module/Core/test-api/Action/QrCodeTest.php delete mode 100644 module/Core/test/Action/QrCodeActionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ae2865..8719b66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. + +### Fixed +* *Nothing* + + ## [4.6.0] - 2025-11-01 ### Added * [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags. diff --git a/Dockerfile b/Dockerfile index 1b69a441..d741d15c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,8 @@ WORKDIR /etc/shlink # Install required PHP extensions RUN \ # Temp install dev dependencies needed to compile the extensions - # FIXME Deprecated image-related extensions. They can be removed with QR-code support - apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \ - docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \ + apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev linux-headers && \ + docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip && \ apk add --no-cache sqlite-libs && \ docker-php-ext-install -j"$(nproc)" pdo_sqlite && \ # Remove temp dev extensions, and install prod equivalents that are required at runtime diff --git a/README.md b/README.md index dc23d7f6..b922144e 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,9 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 8.3 or 8.4 +* PHP 8.4 or 8.5 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * apcu extension is recommended if you don't plan to use RoadRunner. - * xml extension is required if you want to generate QR codes in svg format. * sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance. * MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite. * You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`. diff --git a/composer.json b/composer.json index dc851009..a4841d99 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,6 @@ "doctrine/migrations": "^3.9", "doctrine/orm": "^3.5", "donatj/phpuseragentparser": "^1.10", - "endroid/qr-code": "^6.0.5", "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.1", "guzzlehttp/guzzle": "^7.9", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index a3918aff..c3c63828 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -60,15 +60,6 @@ return [ Option\Tracking\DisableIpTrackingConfigOption::class, Option\Tracking\DisableReferrerTrackingConfigOption::class, Option\Tracking\DisableUaTrackingConfigOption::class, - Option\QrCode\DefaultSizeConfigOption::class, - Option\QrCode\DefaultMarginConfigOption::class, - Option\QrCode\DefaultFormatConfigOption::class, - Option\QrCode\DefaultErrorCorrectionConfigOption::class, - Option\QrCode\DefaultRoundBlockSizeConfigOption::class, - Option\QrCode\DefaultColorConfigOption::class, - Option\QrCode\DefaultBgColorConfigOption::class, - Option\QrCode\DefaultLogoUrlConfigOption::class, - Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class, Option\RabbitMq\RabbitMqUseSslConfigOption::class, diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 1f5425b5..45527697 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -94,14 +94,6 @@ return (static function (): array { ], 'allowed_methods' => [RequestMethodInterface::METHOD_GET], ], - [ - 'name' => CoreAction\QrCodeAction::class, - 'path' => '/{shortCode}/qr-code', - 'middleware' => [ - CoreAction\QrCodeAction::class, - ], - 'allowed_methods' => [RequestMethodInterface::METHOD_GET], - ], [ 'name' => CoreAction\RedirectAction::class, 'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix), diff --git a/config/constants.php b/config/constants.php index 664c8c9f..6ed765e3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -38,20 +38,3 @@ const ISO_COUNTRY_CODES = [ 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW', ]; - -/** @deprecated */ -const DEFAULT_QR_CODE_SIZE = 300; -/** @deprecated */ -const DEFAULT_QR_CODE_MARGIN = 0; -/** @deprecated */ -const DEFAULT_QR_CODE_FORMAT = 'png'; -/** @deprecated */ -const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; -/** @deprecated */ -const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; -/** @deprecated */ -const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; -/** @deprecated */ -const DEFAULT_QR_CODE_COLOR = '#000000'; // Black -/** @deprecated */ -const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json deleted file mode 100644 index dc0f2d81..00000000 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "get": { - "deprecated": true, - "operationId": "shortUrlQrCode", - "tags": [ - "URL Shortener" - ], - "summary": "[Deprecated] Short URL QR code", - "description": "**[Deprecated]** Use an external mechanism to generate QR codes. Shlink dashboard and shlink-web-client provide their own.", - "parameters": [ - { - "$ref": "../parameters/shortCode.json" - }, - { - "name": "size", - "in": "query", - "description": "The size of the image to be returned.", - "required": false, - "schema": { - "type": "integer", - "minimum": 50, - "maximum": 1000, - "default": 300 - } - }, - { - "name": "format", - "in": "query", - "description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.", - "required": false, - "schema": { - "type": "string", - "enum": ["png", "svg"], - "default": "png" - } - }, - { - "name": "margin", - "in": "query", - "description": "The margin around the QR code image.", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0 - } - }, - { - "name": "errorCorrection", - "in": "query", - "description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", - "required": false, - "schema": { - "type": "string", - "enum": ["L", "M", "Q", "H"], - "default": "L" - } - }, - { - "name": "roundBlockSize", - "in": "query", - "description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.", - "required": false, - "schema": { - "type": "string", - "enum": ["true", "false"], - "default": "false" - } - }, - { - "name": "color", - "in": "query", - "description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.", - "required": false, - "schema": { - "type": "string", - "default": "#000000" - } - }, - { - "name": "bgColor", - "in": "query", - "description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.", - "required": false, - "schema": { - "type": "string", - "default": "#ffffff" - } - }, - { - "name": "logo", - "in": "query", - "description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.", - "required": false, - "schema": { - "type": "string", - "enum": ["disable"] - } - } - ], - "responses": { - "200": { - "description": "QR code in PNG format", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "image/svg+xml": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } -} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 1b34b470..bced7510 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -133,9 +133,6 @@ }, "/{shortCode}/track": { "$ref": "paths/{shortCode}_track.json" - }, - "/{shortCode}/qr-code": { - "$ref": "paths/{shortCode}_qr-code.json" } } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 5bb534c2..6ccff061 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -33,7 +33,6 @@ return [ Config\Options\RedirectOptions::class => [Config\Options\RedirectOptions::class, 'fromEnv'], Config\Options\UrlShortenerOptions::class => [Config\Options\UrlShortenerOptions::class, 'fromEnv'], Config\Options\TrackingOptions::class => [Config\Options\TrackingOptions::class, 'fromEnv'], - Config\Options\QrCodeOptions::class => [Config\Options\QrCodeOptions::class, 'fromEnv'], Config\Options\RabbitMqOptions::class => [Config\Options\RabbitMqOptions::class, 'fromEnv'], Config\Options\RobotsOptions::class => [Config\Options\RobotsOptions::class, 'fromEnv'], Config\Options\RealTimeUpdatesOptions::class => [Config\Options\RealTimeUpdatesOptions::class, 'fromEnv'], @@ -103,7 +102,6 @@ return [ Action\RedirectAction::class => ConfigAbstractFactory::class, Action\PixelAction::class => ConfigAbstractFactory::class, - Action\QrCodeAction::class => ConfigAbstractFactory::class, Action\RobotsAction::class => ConfigAbstractFactory::class, EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class, @@ -209,12 +207,6 @@ return [ Util\RedirectResponseHelper::class, ], Action\PixelAction::class => [ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class], - Action\QrCodeAction::class => [ - ShortUrl\ShortUrlResolver::class, - ShortUrl\Helper\ShortUrlStringifier::class, - 'Logger_Shlink', - Config\Options\QrCodeOptions::class, - ], Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Config\Options\RobotsOptions::class], ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [ diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php deleted file mode 100644 index 9bdaf87e..00000000 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ /dev/null @@ -1,161 +0,0 @@ -getQueryParams(); - [$writer, $writerOptions] = self::resolveWriterAndWriterOptions($query, $defaults); - - return new self( - size: self::resolveSize($query, $defaults), - margin: self::resolveMargin($query, $defaults), - writer: $writer, - writerOptions: $writerOptions, - errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults), - roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults), - color: self::resolveColor($query, $defaults), - bgColor: self::resolveBackgroundColor($query, $defaults), - disableLogo: isset($query['logo']) && $query['logo'] === 'disable', - ); - } - - private static function resolveSize(array $query, QrCodeOptions $defaults): int - { - $size = (int) ($query['size'] ?? $defaults->size); - if ($size < self::MIN_SIZE) { - return self::MIN_SIZE; - } - - return min($size, self::MAX_SIZE); - } - - private static function resolveMargin(array $query, QrCodeOptions $defaults): int - { - $margin = $query['margin'] ?? (string) $defaults->margin; - $intMargin = (int) $margin; - if ($margin !== (string) $intMargin) { - return 0; - } - - return max($intMargin, 0); - } - - /** - * @return array{WriterInterface, array} - */ - private static function resolveWriterAndWriterOptions(array $query, QrCodeOptions $defaults): array - { - $qFormat = self::normalizeParam($query['format'] ?? ''); - $format = contains($qFormat, self::SUPPORTED_FORMATS) ? $qFormat : self::normalizeParam($defaults->format); - - return match ($format) { - 'svg' => [new SvgWriter(), []], - default => [new PngWriter(), [PngWriter::WRITER_OPTION_NUMBER_OF_COLORS => null]], - }; - } - - private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevel - { - $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection); - return match ($errorCorrectionLevel) { - 'h' => ErrorCorrectionLevel::High, - 'q' => ErrorCorrectionLevel::Quartile, - 'm' => ErrorCorrectionLevel::Medium, - default => ErrorCorrectionLevel::Low, // 'l' - }; - } - - private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeMode - { - $doNotRoundBlockSize = isset($query['roundBlockSize']) - ? $query['roundBlockSize'] === 'false' - : ! $defaults->roundBlockSize; - return $doNotRoundBlockSize ? RoundBlockSizeMode::None : RoundBlockSizeMode::Margin; - } - - private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface - { - $color = self::normalizeParam($query['color'] ?? $defaults->color); - return self::parseHexColor($color, DEFAULT_QR_CODE_COLOR); - } - - private static function resolveBackgroundColor(array $query, QrCodeOptions $defaults): ColorInterface - { - $bgColor = self::normalizeParam($query['bgColor'] ?? $defaults->bgColor); - return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR); - } - - private static function parseHexColor(string $hexColor, string|null $fallback): Color - { - $hexColor = ltrim($hexColor, '#'); - if (! ctype_xdigit($hexColor) && $fallback !== null) { - return self::parseHexColor($fallback, null); - } - - if (strlen($hexColor) === 3) { - return new Color( - (int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), - (int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), - (int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), - ); - } - - return new Color( - (int) hexdec(substr($hexColor, 0, 2)), - (int) hexdec(substr($hexColor, 2, 2)), - (int) hexdec(substr($hexColor, 4, 2)), - ); - } - - private static function normalizeParam(string $param): string - { - return strtolower(trim($param)); - } -} diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php deleted file mode 100644 index 0ab126bc..00000000 --- a/module/Core/src/Action/QrCodeAction.php +++ /dev/null @@ -1,74 +0,0 @@ -options->enabledForDisabledShortUrls - ? $this->urlResolver->resolvePublicShortUrl($identifier) - : $this->urlResolver->resolveEnabledShortUrl($identifier); - } catch (ShortUrlNotFoundException $e) { - $this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]); - return $handler->handle($request); - } - - $params = QrCodeParams::fromRequest($request, $this->options); - $qrCodeBuilder = new Builder( - writer: $params->writer, - writerOptions: $params->writerOptions, - data: $this->stringifier->stringify($shortUrl), - errorCorrectionLevel: $params->errorCorrectionLevel, - size: $params->size, - margin: $params->margin, - roundBlockSizeMode: $params->roundBlockSizeMode, - foregroundColor: $params->color, - backgroundColor: $params->bgColor, - ); - - return new QrCodeResponse($this->buildQrCode($qrCodeBuilder, $params)); - } - - private function buildQrCode(Builder $qrCodeBuilder, QrCodeParams $params): ResultInterface - { - $logoUrl = $this->options->logoUrl; - if ($logoUrl === null || $params->disableLogo) { - return $qrCodeBuilder->build(); - } - - return $qrCodeBuilder->build( - logoPath: $logoUrl, - logoResizeToHeight: (int) ($params->size / 4), - ); - } -} diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 8613b53f..17b6dd60 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -13,14 +13,6 @@ use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\parseEnvVar; use function sprintf; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE; -use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -97,24 +89,6 @@ enum EnvVars: string /** @deprecated Use REDIRECT_EXTRA_PATH */ case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; - /** @deprecated */ - case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; - /** @deprecated */ - case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; - /** @deprecated */ - case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; - /** @deprecated */ - case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; - /** @deprecated */ - case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; - /** @deprecated */ - case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS'; - /** @deprecated */ - case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR'; - /** @deprecated */ - case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR'; - /** @deprecated */ - case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL'; public function loadFromEnv(): mixed { @@ -173,15 +147,6 @@ enum EnvVars: string self::MERCURE_ENABLED => self::MERCURE_PUBLIC_HUB_URL->existsInEnv(), self::MERCURE_INTERNAL_HUB_URL => self::MERCURE_PUBLIC_HUB_URL->loadFromEnv(), - self::DEFAULT_QR_CODE_SIZE, => DEFAULT_QR_CODE_SIZE, - self::DEFAULT_QR_CODE_MARGIN, => DEFAULT_QR_CODE_MARGIN, - self::DEFAULT_QR_CODE_FORMAT, => DEFAULT_QR_CODE_FORMAT, - self::DEFAULT_QR_CODE_ERROR_CORRECTION, => DEFAULT_QR_CODE_ERROR_CORRECTION, - self::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, => DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, - self::QR_CODE_FOR_DISABLED_SHORT_URLS, => DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, - self::DEFAULT_QR_CODE_COLOR, => DEFAULT_QR_CODE_COLOR, - self::DEFAULT_QR_CODE_BG_COLOR, => DEFAULT_QR_CODE_BG_COLOR, - self::RABBITMQ_ENABLED, self::RABBITMQ_USE_SSL => false, self::RABBITMQ_PORT => 5672, self::RABBITMQ_VHOST => '/', diff --git a/module/Core/src/Config/Options/QrCodeOptions.php b/module/Core/src/Config/Options/QrCodeOptions.php deleted file mode 100644 index e36a4285..00000000 --- a/module/Core/src/Config/Options/QrCodeOptions.php +++ /dev/null @@ -1,48 +0,0 @@ -loadFromEnv(), - margin: (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(), - format: EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(), - errorCorrection: EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(), - roundBlockSize: (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(), - enabledForDisabledShortUrls: (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(), - color: EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(), - bgColor: EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(), - logoUrl: EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(), - ); - } -} diff --git a/module/Core/test-api/Action/QrCodeTest.php b/module/Core/test-api/Action/QrCodeTest.php deleted file mode 100644 index c8285198..00000000 --- a/module/Core/test-api/Action/QrCodeTest.php +++ /dev/null @@ -1,28 +0,0 @@ -callShortUrl('custom/qr-code'); - self::assertEquals(200, $response->getStatusCode()); - - // This short URL allow max 2 visits - $this->callShortUrl('custom'); - $this->callShortUrl('custom'); - - // After 2 visits, the short URL returns a 404, but the QR code should still work - self::assertEquals(404, $this->callShortUrl('custom')->getStatusCode()); - self::assertEquals(200, $this->callShortUrl('custom/qr-code')->getStatusCode()); - } -} diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php deleted file mode 100644 index 5ea4cc47..00000000 --- a/module/Core/test/Action/QrCodeActionTest.php +++ /dev/null @@ -1,303 +0,0 @@ -urlResolver = $this->createMock(ShortUrlResolverInterface::class); - } - - #[Test] - public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void - { - $shortCode = 'abc123'; - $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( - ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); - - $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); - } - - #[Test] - public function aCorrectRequestReturnsTheQrCodeResponse(): void - { - $shortCode = 'abc123'; - $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( - ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willReturn(ShortUrl::createFake()); - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->expects($this->never())->method('handle'); - - $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); - - self::assertInstanceOf(QrCodeResponse::class, $resp); - self::assertEquals(200, $resp->getStatusCode()); - } - - #[Test, DataProvider('provideQueries')] - public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat( - string $defaultFormat, - array $query, - string $expectedContentType, - ): void { - $code = 'abc123'; - $this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::createFake()); - $handler = $this->createMock(RequestHandlerInterface::class); - $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); - - $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $handler); - - self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); - } - - public static function provideQueries(): iterable - { - yield 'no format, png default' => ['png', [], 'image/png']; - yield 'no format, svg default' => ['svg', [], 'image/svg+xml']; - yield 'png format, png default' => ['png', ['format' => 'png'], 'image/png']; - yield 'png format, svg default' => ['svg', ['format' => 'png'], 'image/png']; - yield 'svg format, png default' => ['png', ['format' => 'svg'], 'image/svg+xml']; - yield 'svg format, svg default' => ['svg', ['format' => 'svg'], 'image/svg+xml']; - yield 'unsupported format, png default' => ['png', ['format' => 'jpg'], 'image/png']; - yield 'unsupported format, svg default' => ['svg', ['format' => 'jpg'], 'image/svg+xml']; - } - - #[Test, DataProvider('provideRequestsWithSize')] - public function imageIsReturnedWithExpectedSize( - QrCodeOptions $defaultOptions, - ServerRequestInterface $req, - int $expectedSize, - ): void { - $code = 'abc123'; - $this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::createFake()); - $handler = $this->createMock(RequestHandlerInterface::class); - - $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $handler); - $result = getimagesizefromstring($resp->getBody()->__toString()); - self::assertNotFalse($result); - - [$size] = $result; - self::assertEquals($expectedSize, $size); - } - - public static function provideRequestsWithSize(): iterable - { - yield 'different margin and size defaults' => [ - new QrCodeOptions(size: 660, margin: 40), - ServerRequestFactory::fromGlobals(), - 740, - ]; - yield 'no size' => [new QrCodeOptions(), ServerRequestFactory::fromGlobals(), 300]; - yield 'no size, different default' => [new QrCodeOptions(size: 500), ServerRequestFactory::fromGlobals(), 500]; - yield 'size in query' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), - 123, - ]; - yield 'size in query, default margin' => [ - new QrCodeOptions(margin: 25), - ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), - 173, - ]; - yield 'margin' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), - 370, - ]; - yield 'margin and different default' => [ - new QrCodeOptions(size: 400), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), - 470, - ]; - yield 'margin and size' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '100', 'size' => '200']), - 400, - ]; - yield 'negative margin' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), - 300, - ]; - yield 'negative margin, default margin' => [ - new QrCodeOptions(margin: 10), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), - 300, - ]; - yield 'non-numeric margin' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']), - 300, - ]; - yield 'negative margin and size' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']), - 150, - ]; - yield 'negative margin and size, default margin' => [ - new QrCodeOptions(margin: 5), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']), - 150, - ]; - yield 'non-numeric margin and size' => [ - new QrCodeOptions(), - ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo', 'size' => '538']), - 538, - ]; - } - - #[Test, DataProvider('provideRoundBlockSize')] - public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( - QrCodeOptions $defaultOptions, - string|null $roundBlockSize, - int $expectedColor, - ): void { - $code = 'abc123'; - $req = ServerRequestFactory::fromGlobals() - ->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize]) - ->withAttribute('shortCode', $code); - - $this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::withLongUrl('https://shlink.io')); - $handler = $this->createMock(RequestHandlerInterface::class); - - $resp = $this->action($defaultOptions)->process($req, $handler); - $image = imagecreatefromstring($resp->getBody()->__toString()); - self::assertNotFalse($image); - - $color = imagecolorat($image, 1, 1); - self::assertEquals($expectedColor, $color); - } - - public static function provideRoundBlockSize(): iterable - { - yield 'no round block param' => [new QrCodeOptions(), null, self::WHITE]; - yield 'no round block param, but disabled by default' => [ - new QrCodeOptions(roundBlockSize: false), - null, - self::BLACK, - ]; - yield 'round block: "true"' => [new QrCodeOptions(), 'true', self::WHITE]; - yield 'round block: "true", but disabled by default' => [ - new QrCodeOptions(roundBlockSize: false), - 'true', - self::WHITE, - ]; - yield 'round block: "false"' => [new QrCodeOptions(), 'false', self::BLACK]; - yield 'round block: "false", but enabled by default' => [ - new QrCodeOptions(roundBlockSize: true), - 'false', - self::BLACK, - ]; - } - - #[Test, DataProvider('provideColors')] - public function properColorsAreUsed(string|null $queryColor, string|null $optionsColor, int $expectedColor): void - { - $code = 'abc123'; - $req = ServerRequestFactory::fromGlobals() - ->withQueryParams(['color' => $queryColor]) - ->withAttribute('shortCode', $code); - - $this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::withLongUrl('https://shlink.io')); - $handler = $this->createMock(RequestHandlerInterface::class); - - $resp = $this->action( - new QrCodeOptions(size: 250, roundBlockSize: false, color: $optionsColor ?? DEFAULT_QR_CODE_COLOR), - )->process($req, $handler); - $image = imagecreatefromstring($resp->getBody()->__toString()); - self::assertNotFalse($image); - - $resultingColor = imagecolorat($image, 1, 1); - self::assertEquals($expectedColor, $resultingColor); - } - - public static function provideColors(): iterable - { - yield 'no query, no default' => [null, null, self::BLACK]; - yield '6-char-query black' => ['000000', null, self::BLACK]; - yield '6-char-query white' => ['ffffff', null, self::WHITE]; - yield '6-char-query red' => ['ff0000', null, (int) hexdec('ff0000')]; - yield '3-char-query black' => ['000', null, self::BLACK]; - yield '3-char-query white' => ['fff', null, self::WHITE]; - yield '3-char-query red' => ['f00', null, (int) hexdec('ff0000')]; - yield '3-char-default red' => [null, 'f00', (int) hexdec('ff0000')]; - yield 'invalid color in query' => ['zzzzzzzz', null, self::BLACK]; - yield 'invalid color in query with default' => ['zzzzzzzz', 'aa88cc', self::BLACK]; - yield 'invalid color in default' => [null, 'zzzzzzzz', self::BLACK]; - } - - #[Test, DataProvider('provideEnabled')] - public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls): void - { - if ($enabledForDisabledShortUrls) { - $this->urlResolver->expects($this->once())->method('resolvePublicShortUrl')->willThrowException( - ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')), - ); - $this->urlResolver->expects($this->never())->method('resolveEnabledShortUrl'); - } else { - $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->willThrowException( - ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')), - ); - $this->urlResolver->expects($this->never())->method('resolvePublicShortUrl'); - } - - $options = new QrCodeOptions(enabledForDisabledShortUrls: $enabledForDisabledShortUrls); - $this->action($options)->process( - ServerRequestFactory::fromGlobals(), - $this->createMock(RequestHandlerInterface::class), - ); - } - - public static function provideEnabled(): iterable - { - yield 'always enabled' => [true]; - yield 'only enabled short URLs' => [false]; - } - - public function action(QrCodeOptions|null $options = null): QrCodeAction - { - return new QrCodeAction( - $this->urlResolver, - new ShortUrlStringifier(), - new NullLogger(), - $options ?? new QrCodeOptions(enabledForDisabledShortUrls: false), - ); - } -} From c2aae9640d2159c6770b8a359ef527d6729c8e60 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 7 Nov 2025 17:07:34 +0100 Subject: [PATCH 03/71] Remove requirement on ext-gd --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index a4841d99..7020756e 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,6 @@ "require": { "php": "^8.3", "ext-curl": "*", - "ext-gd": "*", "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", @@ -46,7 +45,7 @@ "shlinkio/shlink-config": "^4.0", "shlinkio/shlink-event-dispatcher": "^4.3", "shlinkio/shlink-importer": "^5.6", - "shlinkio/shlink-installer": "^9.7", + "shlinkio/shlink-installer": "dev-develop#2b9e6bd as 10.0.0", "shlinkio/shlink-ip-geolocation": "^4.4", "shlinkio/shlink-json": "^1.2", "spiral/roadrunner": "^2025.1", From c3961b139ab7f74f8f930120c40eded98fb07c6e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 7 Nov 2025 17:10:51 +0100 Subject: [PATCH 04/71] Remove image extensions from dev docker containers --- data/infra/frankenphp.Dockerfile | 3 --- data/infra/php.Dockerfile | 3 --- data/infra/roadrunner.Dockerfile | 3 --- 3 files changed, 9 deletions(-) diff --git a/data/infra/frankenphp.Dockerfile b/data/infra/frankenphp.Dockerfile index 22e3e580..3670ced3 100644 --- a/data/infra/frankenphp.Dockerfile +++ b/data/infra/frankenphp.Dockerfile @@ -24,9 +24,6 @@ RUN docker-php-ext-install intl RUN apk add --no-cache libzip-dev zlib-dev RUN docker-php-ext-install zip -RUN apk add --no-cache libpng-dev -RUN docker-php-ext-install gd - RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index bc06e876..fc71471c 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -25,9 +25,6 @@ RUN docker-php-ext-install intl RUN apk add --no-cache libzip-dev zlib-dev RUN docker-php-ext-install zip -RUN apk add --no-cache libpng-dev -RUN docker-php-ext-install gd - RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 6f5981bf..23adc1f7 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -24,9 +24,6 @@ RUN docker-php-ext-install intl RUN apk add --no-cache libzip-dev zlib-dev RUN docker-php-ext-install zip -RUN apk add --no-cache libpng-dev -RUN docker-php-ext-install gd - RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql From 1eb1f5344c7d24b481c81466176d49f378799568 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 7 Nov 2025 17:20:45 +0100 Subject: [PATCH 05/71] Drop support for PHP 8.3 --- .github/workflows/ci-db-tests.yml | 8 ++++---- .github/workflows/ci-tests.yml | 8 ++++---- .github/workflows/ci.yml | 13 ++++++------- .github/workflows/publish-openapi-spec.yml | 4 ++-- .github/workflows/publish-release.yml | 10 +++++----- CHANGELOG.md | 1 + composer.json | 2 +- data/infra/examples/nginx-vhost.conf | 2 +- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index 639481b8..a8655a4d 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -13,11 +13,11 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.3', '8.4', '8.5'] + php-version: ['8.4', '8.5'] env: LC_ALL: C steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install MSSQL ODBC if: ${{ inputs.platform == 'ms' }} run: sudo ./data/infra/ci/install-ms-odbc.sh @@ -35,8 +35,8 @@ jobs: - name: Run tests run: composer test:db:${{ inputs.platform }} - name: Upload code coverage - uses: actions/upload-artifact@v4 - if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }} + uses: actions/upload-artifact@v5 + if: ${{ matrix.php-version == '8.4' && inputs.platform == 'sqlite:ci' }} with: name: coverage-db path: | diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 1ee23377..5f9f2bdc 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -13,11 +13,11 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.3', '8.4', '8.5'] + php-version: ['8.4', '8.5'] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Start postgres database server if: ${{ inputs.test-group == 'api' }} run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres @@ -32,8 +32,8 @@ jobs: if: ${{ inputs.test-group == 'api' }} run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - run: composer test:${{ inputs.test-group }}:ci - - uses: actions/upload-artifact@v4 - if: ${{ matrix.php-version == '8.3' }} + - uses: actions/upload-artifact@v5 + if: ${{ matrix.php-version == '8.4' }} with: name: coverage-${{ inputs.test-group }} path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51803a4f..bcf85be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,10 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.3'] + php-version: ['8.4'] command: ['cs', 'stan', 'openapi:validate'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} @@ -69,16 +69,15 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.3'] + php-version: ['8.4'] steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Use PHP uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} extensions-cache-key: tests-extensions-${{ matrix.php-version }} - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: build - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov @@ -87,7 +86,7 @@ jobs: - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - run: vendor/bin/phpcov merge build --clover build/clover.xml - name: Publish coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: file: ./build/clover.xml diff --git a/.github/workflows/publish-openapi-spec.yml b/.github/workflows/publish-openapi-spec.yml index 6195ce90..7a48e115 100644 --- a/.github/workflows/publish-openapi-spec.yml +++ b/.github/workflows/publish-openapi-spec.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.3'] + php-version: ['8.4'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Determine version id: determine_version run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 61fc6940..ee0ceb79 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,16 +10,16 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.3', '8.4', '8.5'] + php-version: ['8.4', '8.4', '8.5'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - run: ./build.sh ${GITHUB_REF#refs/tags/v} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: dist-files-${{ matrix.php-version }} path: build @@ -28,8 +28,8 @@ jobs: needs: ['build'] runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v6 with: path: build - name: Publish release with assets diff --git a/CHANGELOG.md b/CHANGELOG.md index 8719b66d..eee96a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Removed * [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. +* [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3. ### Fixed * *Nothing* diff --git a/composer.json b/composer.json index 7020756e..a4669587 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index b7a5d4fa..353db74e 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -11,7 +11,7 @@ server { location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.4-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } From 91fd5809ff893ba508035f36e070ffed916ca3a3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 08:28:52 +0100 Subject: [PATCH 06/71] Remove REDIRECT_APPEND_EXTRA_PATH env var --- CHANGELOG.md | 3 ++- module/Core/src/Config/EnvVars.php | 8 ++------ module/Core/src/Config/Options/UrlShortenerOptions.php | 9 +-------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eee96a9b..0098a519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Removed -* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. * [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3. +* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. +* [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead. ### Fixed * *Nothing* diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 17b6dd60..f42d6894 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; +use Shlinkio\Shlink\Core\Config\Options\ExtraPathMode; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use function date_default_timezone_get; @@ -87,9 +88,6 @@ enum EnvVars: string case TRUSTED_PROXIES = 'TRUSTED_PROXIES'; case LOGS_FORMAT = 'LOGS_FORMAT'; - /** @deprecated Use REDIRECT_EXTRA_PATH */ - case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; - public function loadFromEnv(): mixed { return env($this->value) ?? $this->loadFromFileEnv() ?? $this->defaultValue(); @@ -126,9 +124,7 @@ enum EnvVars: string self::SHORT_URL_TRAILING_SLASH => false, self::DEFAULT_DOMAIN, self::BASE_PATH => '', self::CACHE_NAMESPACE => 'Shlink', - // Deprecated. In Shlink 5.0.0, add default value for REDIRECT_EXTRA_PATH_MODE - self::REDIRECT_APPEND_EXTRA_PATH => false, - // self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value, + self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value, self::REDIS_PUB_SUB_ENABLED, self::MATOMO_ENABLED, diff --git a/module/Core/src/Config/Options/UrlShortenerOptions.php b/module/Core/src/Config/Options/UrlShortenerOptions.php index 3d111447..38fc172d 100644 --- a/module/Core/src/Config/Options/UrlShortenerOptions.php +++ b/module/Core/src/Config/Options/UrlShortenerOptions.php @@ -36,15 +36,8 @@ final readonly class UrlShortenerOptions MIN_SHORT_CODES_LENGTH, ); - // Deprecated. Initialize extra path from REDIRECT_APPEND_EXTRA_PATH. - $appendExtraPath = EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(); - $extraPathMode = $appendExtraPath ? ExtraPathMode::APPEND : ExtraPathMode::DEFAULT; - - // If REDIRECT_EXTRA_PATH_MODE was explicitly provided, it has precedence $extraPathModeFromEnv = EnvVars::REDIRECT_EXTRA_PATH_MODE->loadFromEnv(); - if ($extraPathModeFromEnv !== null) { - $extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT; - } + $extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT; return new self( defaultDomain: EnvVars::DEFAULT_DOMAIN->loadFromEnv(), From 9f564b97858c8d145bc2a864146f40713031c6ad Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 09:16:15 +0100 Subject: [PATCH 07/71] Do not allow API keys to be disabled by plain-text key --- CHANGELOG.md | 1 + .../CLI/src/Command/Api/DisableKeyCommand.php | 42 ++++---------- .../Command/Api/DisableKeyCommandTest.php | 55 ++----------------- module/Rest/src/Service/ApiKeyService.php | 13 ----- .../src/Service/ApiKeyServiceInterface.php | 6 -- .../Rest/test/Service/ApiKeyServiceTest.php | 22 +++----- 6 files changed, 26 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0098a519..5ec25b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3. * [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. * [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead. +* [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name. ### Fixed * *Nothing* diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 308c432f..46380db8 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -9,7 +9,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -20,24 +19,17 @@ use function sprintf; #[AsCommand( name: DisableKeyCommand::NAME, - description: 'Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)', + description: 'Disables an API key by name', help: <<%command.name% command allows you to disable an existing API key, via its name or the - plain-text key. + The %command.name% command allows you to disable an existing API key. If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys. %command.full_name% - You can optionally pass the API key name to be disabled. In that case --by-name is also - required, to indicate the first argument is the API key name and not the plain-text key: + You can optionally pass the API key name to be disabled: - %command.full_name% the_key_name --by-name - - You can pass the plain-text key to be disabled, but that is DEPRECATED. In next major version, - the argument will always be assumed to be the name: - - %command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143 + %command.full_name% the_key_name HELP, )] @@ -52,41 +44,31 @@ class DisableKeyCommand extends Command protected function interact(InputInterface $input, OutputInterface $output): void { - $keyOrName = $input->getArgument('key-or-name'); + $name = $input->getArgument('name'); - if ($keyOrName === null) { + if ($name === null) { $apiKeys = $this->apiKeyService->listKeys(enabledOnly: true); - $name = (new SymfonyStyle($input, $output))->choice( + $name = new SymfonyStyle($input, $output)->choice( 'What API key do you want to disable?', map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), ); - $input->setArgument('key-or-name', $name); - $input->setOption('by-name', true); + $input->setArgument('name', $name); } } public function __invoke( SymfonyStyle $io, - #[Argument( - description: 'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.', - )] - string|null $keyOrName = null, - #[Option(description: 'Indicates the first argument is the API key name, not the plain-text key.')] - bool $byName = false, + #[Argument('The name of the API key to disable.')] string|null $name = null, ): int { - if ($keyOrName === null) { + if ($name === null) { $io->warning('An API key name was not provided.'); return Command::INVALID; } try { - if ($byName) { - $this->apiKeyService->disableByName($keyOrName); - } else { - $this->apiKeyService->disableByKey($keyOrName); - } - $io->success(sprintf('API key "%s" properly disabled', $keyOrName)); + $this->apiKeyService->disableByName($name); + $io->success(sprintf('API key "%s" properly disabled', $name)); return Command::SUCCESS; } catch (InvalidArgumentException $e) { $io->error($e->getMessage()); diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 85918305..3a6c93b5 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -31,12 +31,9 @@ class DisableKeyCommandTest extends TestCase public function providedApiKeyIsDisabled(): void { $apiKey = 'abcd1234'; - $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey); - $this->apiKeyService->expects($this->never())->method('disableByName'); + $this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey); - $exitCode = $this->commandTester->execute([ - 'key-or-name' => $apiKey, - ]); + $exitCode = $this->commandTester->execute(['name' => $apiKey]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('API key "abcd1234" properly disabled', $output); @@ -44,55 +41,15 @@ class DisableKeyCommandTest extends TestCase } #[Test] - 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([ - 'key-or-name' => $name, - '--by-name' => true, - ]); - $output = $this->commandTester->getDisplay(); - - self::assertStringContainsString('API key "the key to delete" properly disabled', $output); - self::assertEquals(Command::SUCCESS, $exitCode); - } - - #[Test] - public function errorIsReturnedIfDisableByKeyThrowsException(): void + public function errorIsReturnedIfDisableByNameThrowsException(): void { $apiKey = 'abcd1234'; $expectedMessage = 'API key "abcd1234" does not exist.'; - $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException( + $this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey)->willThrowException( new InvalidArgumentException($expectedMessage), ); - $this->apiKeyService->expects($this->never())->method('disableByName'); - $exitCode = $this->commandTester->execute([ - 'key-or-name' => $apiKey, - ]); - $output = $this->commandTester->getDisplay(); - - self::assertStringContainsString($expectedMessage, $output); - self::assertEquals(Command::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([ - 'key-or-name' => $name, - '--by-name' => true, - ]); + $exitCode = $this->commandTester->execute(['name' => $apiKey]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString($expectedMessage, $output); @@ -103,7 +60,6 @@ class DisableKeyCommandTest extends TestCase 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]); @@ -121,7 +77,6 @@ class DisableKeyCommandTest extends TestCase 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([]); diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index a5a85b79..d717126d 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -92,19 +92,6 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $this->disableApiKey($apiKey); } - /** - * @inheritDoc - */ - public function disableByKey(string $key): ApiKey - { - $apiKey = $this->findByKey($key); - if ($apiKey === null) { - throw ApiKeyNotFoundException::forKey($key); - } - - return $this->disableApiKey($apiKey); - } - private function disableApiKey(ApiKey $apiKey): ApiKey { $apiKey->disable(); diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 6eb3df0f..ebd78259 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -32,12 +32,6 @@ interface ApiKeyServiceInterface */ public function disableByName(string $apiKeyName): ApiKey; - /** - * @deprecated Use `self::disableByName($name)` instead - * @throws ApiKeyNotFoundException - */ - public function disableByKey(string $key): ApiKey; - /** * @return ApiKey[] */ diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 146ce0ac..3efe3f43 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -143,35 +143,29 @@ class ApiKeyServiceTest extends TestCase self::assertSame($apiKey, $result->apiKey); } - #[Test, DataProvider('provideDisableArgs')] - public function disableThrowsExceptionWhenNoApiKeyIsFound(string $disableMethod, array $findOneByArg): void + #[Test] + public function disableThrowsExceptionWhenNoApiKeyIsFound(): void { - $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn(null); + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => '12345'])->willReturn(null); $this->expectException(ApiKeyNotFoundException::class); - $this->service->{$disableMethod}('12345'); + $this->service->disableByName('12345'); } - #[Test, DataProvider('provideDisableArgs')] - public function disableReturnsDisabledApiKeyWhenFound(string $disableMethod, array $findOneByArg): void + #[Test] + public function disableReturnsDisabledApiKeyWhenFound(): void { $key = ApiKey::create(); - $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn($key); + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => '12345'])->willReturn($key); $this->em->expects($this->once())->method('flush'); self::assertTrue($key->isEnabled()); - $returnedKey = $this->service->{$disableMethod}('12345'); + $returnedKey = $this->service->disableByName('12345'); self::assertFalse($key->isEnabled()); self::assertSame($key, $returnedKey); } - public static function provideDisableArgs(): iterable - { - yield 'disableByKey' => ['disableByKey', ['key' => ApiKey::hashKey('12345')]]; - yield 'disableByName' => ['disableByName', ['name' => '12345']]; - } - #[Test] public function listFindsAllApiKeys(): void { From 8bafd82e1d26bb5e583609726bf6649f557fea92 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 10:05:07 +0100 Subject: [PATCH 08/71] Remove deprecated options from short-url:list command --- CHANGELOG.md | 1 + config/config.php | 4 ++-- module/CLI/src/Command/Api/DeleteKeyCommand.php | 2 +- module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php | 8 ++------ .../test/Command/ShortUrl/ListShortUrlsCommandTest.php | 2 +- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec25b8a..61e3a31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. * [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead. * [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name. +* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` deprecated options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead. ### Fixed * *Nothing* diff --git a/config/config.php b/config/config.php index 92c69ba0..ed7c5b3c 100644 --- a/config/config.php +++ b/config/config.php @@ -10,7 +10,7 @@ use Mezzio; use Mezzio\ProblemDetails; use Shlinkio\Shlink\Core\Config\EnvVars; -return (new ConfigAggregator\ConfigAggregator( +return new ConfigAggregator\ConfigAggregator( providers: [ Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, @@ -39,4 +39,4 @@ return (new ConfigAggregator\ConfigAggregator( Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, ], -))->getMergedConfig(); +)->getMergedConfig(); diff --git a/module/CLI/src/Command/Api/DeleteKeyCommand.php b/module/CLI/src/Command/Api/DeleteKeyCommand.php index f57a5e4a..8b9db376 100644 --- a/module/CLI/src/Command/Api/DeleteKeyCommand.php +++ b/module/CLI/src/Command/Api/DeleteKeyCommand.php @@ -48,7 +48,7 @@ class DeleteKeyCommand extends Command if ($apiKeyName === null) { $apiKeys = $this->apiKeyService->listKeys(); - $name = (new SymfonyStyle($input, $output))->choice( + $name = new SymfonyStyle($input, $output)->choice( 'What API key do you want to delete?', map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), ); diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index d850e831..c1c5df4f 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -73,7 +73,6 @@ class ListShortUrlsCommand extends Command InputOption::VALUE_REQUIRED, 'Used to filter results by domain. Use DEFAULT keyword to filter by default domain', ) - ->addOption('including-all-tags', 'i', InputOption::VALUE_NONE, '[DEPRECATED] Use --tags-all instead') ->addOption( 'tags-all', mode: InputOption::VALUE_NONE, @@ -133,7 +132,6 @@ class ListShortUrlsCommand extends Command 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', @@ -148,9 +146,7 @@ class ListShortUrlsCommand extends Command $io = new SymfonyStyle($input, $output); $page = (int) $input->getOption('page'); - $tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true - ? TagsMode::ALL->value - : TagsMode::ANY->value; + $tagsMode = $input->getOption('tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $data = [ @@ -249,7 +245,7 @@ class ListShortUrlsCommand extends Command $columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string => $shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY; } - if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) { + if ($input->getOption('show-api-key')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null => $shortUrl->authorApiKey?->name; } diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 8d75322e..76d1882f 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -231,7 +231,7 @@ class ListShortUrlsCommandTest extends TestCase { yield [[], 1, null, [], TagsMode::ANY->value]; yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value]; - yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value]; + yield [['--tags-all' => true], 1, null, [], TagsMode::ALL->value]; yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value]; yield [ ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']], From 94adba95ebe75d22e5f5f3960bf6f394545753d6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 10:09:20 +0100 Subject: [PATCH 09/71] Fix codecov/codecov-action arguments for v5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcf85be9..acd25100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: - name: Publish coverage uses: codecov/codecov-action@v5 with: - file: ./build/clover.xml + files: ./build/clover.xml delete-artifacts: needs: From 359129f5866d370a09c023fdc4d3e922d322712e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 10:21:06 +0100 Subject: [PATCH 10/71] Remove deprecated --tags option in console commands --- CHANGELOG.md | 1 + .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- module/CLI/src/Input/TagsOption.php | 16 +++------------- .../ShortUrl/CreateShortUrlCommandTest.php | 2 +- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e3a31a..a63660aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead. * [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name. * [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` deprecated options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead. +* [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag. ### Fixed * *Nothing* diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index c1c5df4f..99c445f0 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -76,7 +76,7 @@ class ListShortUrlsCommand extends Command ->addOption( 'tags-all', mode: InputOption::VALUE_NONE, - description: 'If --tags is provided, returns only short URLs including ALL of them', + description: 'If --tag is provided, returns only short URLs including ALL of them', ) ->addOption( 'exclude-tag', diff --git a/module/CLI/src/Input/TagsOption.php b/module/CLI/src/Input/TagsOption.php index ff02a735..1cdee7e8 100644 --- a/module/CLI/src/Input/TagsOption.php +++ b/module/CLI/src/Input/TagsOption.php @@ -8,10 +8,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; -use function array_map; use function array_unique; -use function Shlinkio\Shlink\Core\ArrayUtils\flatten; -use function Shlinkio\Shlink\Core\splitByComma; readonly class TagsOption { @@ -23,20 +20,15 @@ readonly class TagsOption 't', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, $description, - ) - ->addOption( - 'tags', - mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - description: '[DEPRECATED] Use --tag instead', ); } /** - * Whether tags have been set or not, via `--tag`, `-t` or the deprecated `--tags` + * Whether tags have been set or not, via `--tag` or `-t` */ public function exists(InputInterface $input): bool { - return $input->hasParameterOption(['--tag', '-t']) || $input->hasParameterOption('--tags'); + return $input->hasParameterOption(['--tag', '-t']); } /** @@ -44,8 +36,6 @@ readonly class TagsOption */ public function get(InputInterface $input): array { - // FIXME DEPRECATED Remove support for comma-separated tags in next major release - $tags = [...$input->getOption('tag'), ...$input->getOption('tags')]; - return array_unique(flatten(array_map(splitByComma(...), $tags))); + return array_unique($input->getOption('tag')); } } diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 728f8f22..ff5e3ea6 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -95,7 +95,7 @@ class CreateShortUrlCommandTest extends TestCase $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', - '--tags' => ['foo,bar', 'baz', 'boo,zar,baz'], + '--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'], ]); $output = $this->commandTester->getDisplay(); From 63bea36c0577777cb7499f674b4d537b1e288e9a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 10:30:06 +0100 Subject: [PATCH 11/71] Remove workaround to detect trusted proxies automatically --- CHANGELOG.md | 4 +- config/autoload/ip-address.global.php | 13 ---- ...eForwardedAddressesMiddlewareDecorator.php | 51 ---------------- ...wardedAddressesMiddlewareDecoratorTest.php | 59 ------------------- 4 files changed, 3 insertions(+), 124 deletions(-) delete mode 100644 module/Core/src/Middleware/ReverseForwardedAddressesMiddlewareDecorator.php delete mode 100644 module/Core/test/Middleware/ReverseForwardedAddressesMiddlewareDecoratorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a63660aa..5c97a1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Changed -* *Nothing* +* [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue. + + Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink. ### Deprecated * *Nothing* diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php index 11902091..83766171 100644 --- a/config/autoload/ip-address.global.php +++ b/config/autoload/ip-address.global.php @@ -5,7 +5,6 @@ declare(strict_types=1); use RKA\Middleware\IpAddress; use RKA\Middleware\Mezzio\IpAddressFactory; use Shlinkio\Shlink\Core\Config\EnvVars; -use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator; use function Shlinkio\Shlink\Core\splitByComma; @@ -43,18 +42,6 @@ return (static function (): array { 'factories' => [ IpAddress::class => IpAddressFactory::class, ], - 'delegators' => [ - // Make middleware decoration transparent to other parts of the code - IpAddress::class => [ - fn ($c, $n, callable $callback) => - // If trusted proxies have been provided, use original middleware verbatim, otherwise decorate - // with workaround - $trustedProxies !== null - ? $callback() - : new ReverseForwardedAddressesMiddlewareDecorator($callback()), - ], - ], - ], ]; diff --git a/module/Core/src/Middleware/ReverseForwardedAddressesMiddlewareDecorator.php b/module/Core/src/Middleware/ReverseForwardedAddressesMiddlewareDecorator.php deleted file mode 100644 index 3a86b129..00000000 --- a/module/Core/src/Middleware/ReverseForwardedAddressesMiddlewareDecorator.php +++ /dev/null @@ -1,51 +0,0 @@ -hasHeader(self::FORWARDED_FOR_HEADER)) { - $request = $request->withHeader( - self::FORWARDED_FOR_HEADER, - implode(',', array_reverse(explode(',', $request->getHeaderLine(self::FORWARDED_FOR_HEADER)))), - ); - } - - return $this->wrappedMiddleware->process($request, $handler); - } -} diff --git a/module/Core/test/Middleware/ReverseForwardedAddressesMiddlewareDecoratorTest.php b/module/Core/test/Middleware/ReverseForwardedAddressesMiddlewareDecoratorTest.php deleted file mode 100644 index d3e1bd5e..00000000 --- a/module/Core/test/Middleware/ReverseForwardedAddressesMiddlewareDecoratorTest.php +++ /dev/null @@ -1,59 +0,0 @@ -decoratedMiddleware = $this->createMock(MiddlewareInterface::class); - $this->requestHandler = $this->createMock(RequestHandlerInterface::class); - $this->middleware = new ReverseForwardedAddressesMiddlewareDecorator($this->decoratedMiddleware); - } - - #[Test] - public function processesRequestAsIsWhenHeadersIsNotFound(): void - { - $request = ServerRequestFactory::fromGlobals(); - $this->decoratedMiddleware->expects($this->once())->method('process')->with( - $request, - $this->requestHandler, - )->willReturn(new Response()); - - $this->middleware->process($request, $this->requestHandler); - } - - #[Test] - public function revertsListOfAddressesWhenHeaderIsFound(): void - { - $request = ServerRequestFactory::fromGlobals()->withHeader( - ReverseForwardedAddressesMiddlewareDecorator::FORWARDED_FOR_HEADER, - '1.2.3.4,5.6.7.8,9.10.11.12', - ); - - $this->decoratedMiddleware->expects($this->once())->method('process')->with( - $this->callback(fn (ServerRequestInterface $req): bool => $req->getHeaderLine( - ReverseForwardedAddressesMiddlewareDecorator::FORWARDED_FOR_HEADER, - ) === '9.10.11.12,5.6.7.8,1.2.3.4'), - $this->requestHandler, - )->willReturn(new Response()); - - $this->middleware->process($request, $this->requestHandler); - } -} From a731e01bd42f1edf56bb3043f0d4b34741fd2824 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 10:41:03 +0100 Subject: [PATCH 12/71] Remove test covering trusted proxies workaround --- module/Core/test-api/Action/RedirectTest.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index bc2d0c70..8ef1edb5 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -106,15 +106,6 @@ class RedirectTest extends ApiTestCase 'https://example.com/static-ip-address', ]; } - - yield 'rule: IP address in "X-Forwarded-For" together with proxy addresses' => [ - [ - RequestOptions::HEADERS => [ - 'X-Forwarded-For' => '1.2.3.4, 192.168.1.1, 192.168.1.2', - ], - ], - 'https://example.com/static-ip-address', - ]; } /** From c42fb67efcc1ed9a911a9e5fe01b35a1fa8e04d6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Nov 2025 22:47:24 +0100 Subject: [PATCH 13/71] Simplify NotFoundRedirectConfigInterface with property hooks and asymetric visibility --- .../Command/Domain/DomainRedirectsCommand.php | 6 +-- .../src/Command/Domain/ListDomainsCommand.php | 6 +-- .../Command/Domain/ListDomainsCommandTest.php | 4 +- .../Config/EmptyNotFoundRedirectConfig.php | 32 ++------------ .../NotFoundRedirectConfigInterface.php | 14 ++----- .../src/Config/NotFoundRedirectResolver.php | 7 ++-- module/Core/src/Config/NotFoundRedirects.php | 10 ++--- .../Options/NotFoundRedirectOptions.php | 42 +++---------------- module/Core/src/Domain/Entity/Domain.php | 36 ++-------------- .../EmptyNotFoundRedirectConfigTest.php | 9 ++-- .../Config/NotFoundRedirectResolverTest.php | 18 ++++---- module/Core/test/Domain/DomainServiceTest.php | 6 +-- .../Domain/Request/DomainRedirectsRequest.php | 6 +-- .../Domain/DomainRedirectsActionTest.php | 6 +-- .../Request/DomainRedirectsRequestTest.php | 4 +- 15 files changed, 54 insertions(+), 152 deletions(-) diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index 1e272c12..fc94ee49 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -88,15 +88,15 @@ class DomainRedirectsCommand extends Command $this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects( $ask( 'URL to redirect to when a user hits this domain\'s base URL', - $domain?->baseUrlRedirect(), + $domain?->baseUrlRedirect, ), $ask( 'URL to redirect to when a user hits a not found URL other than an invalid short URL', - $domain?->regular404Redirect(), + $domain?->regular404Redirect, ), $ask( 'URL to redirect to when a user hits an invalid short URL', - $domain?->invalidShortUrlRedirect(), + $domain?->invalidShortUrlRedirect, ), )); diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index a66d6d7e..d33d1ed6 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -59,9 +59,9 @@ class ListDomainsCommand extends Command private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string { - $baseUrl = $config->baseUrlRedirect() ?? 'N/A'; - $regular404 = $config->regular404Redirect() ?? 'N/A'; - $invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A'; + $baseUrl = $config->baseUrlRedirect ?? 'N/A'; + $regular404 = $config->regular404Redirect ?? 'N/A'; + $invalidShortUrl = $config->invalidShortUrlRedirect ?? 'N/A'; return <<domainService->expects($this->once())->method('listDomains')->with()->willReturn([ DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions( - invalidShortUrl: 'https://foo.com/default/invalid', - baseUrl: 'https://foo.com/default/base', + invalidShortUrlRedirect: 'https://foo.com/default/invalid', + baseUrlRedirect: 'https://foo.com/default/base', )), DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')), DomainItem::forNonDefaultDomain($bazDomain), diff --git a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php index 5ec23bc1..48950829 100644 --- a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php +++ b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php @@ -6,33 +6,7 @@ namespace Shlinkio\Shlink\Core\Config; final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): string|null - { - return null; - } - - public function hasInvalidShortUrlRedirect(): bool - { - return false; - } - - public function regular404Redirect(): string|null - { - return null; - } - - public function hasRegular404Redirect(): bool - { - return false; - } - - public function baseUrlRedirect(): string|null - { - return null; - } - - public function hasBaseUrlRedirect(): bool - { - return false; - } + private(set) string|null $invalidShortUrlRedirect = null; + private(set) string|null $regular404Redirect = null; + private(set) string|null $baseUrlRedirect = null; } diff --git a/module/Core/src/Config/NotFoundRedirectConfigInterface.php b/module/Core/src/Config/NotFoundRedirectConfigInterface.php index 46c2c734..40b26f58 100644 --- a/module/Core/src/Config/NotFoundRedirectConfigInterface.php +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -6,15 +6,7 @@ namespace Shlinkio\Shlink\Core\Config; interface NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): string|null; - - public function hasInvalidShortUrlRedirect(): bool; - - public function regular404Redirect(): string|null; - - public function hasRegular404Redirect(): bool; - - public function baseUrlRedirect(): string|null; - - public function hasBaseUrlRedirect(): bool; + public string|null $invalidShortUrlRedirect { get; } + public string|null $regular404Redirect { get; } + public string|null $baseUrlRedirect { get; } } diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index dbdf8151..d32ad232 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -32,10 +32,9 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface UriInterface $currentUri, ): ResponseInterface|null { $urlToRedirectTo = match (true) { - $notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(), - $notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(), - $notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() => - $config->invalidShortUrlRedirect(), + $notFoundType->isBaseUrl() => $config->baseUrlRedirect, + $notFoundType->isRegularNotFound() => $config->regular404Redirect, + $notFoundType->isInvalidShortUrl() => $config->invalidShortUrlRedirect, default => null, }; diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 2753d44f..c202edd3 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -6,12 +6,12 @@ namespace Shlinkio\Shlink\Core\Config; use JsonSerializable; -final class NotFoundRedirects implements JsonSerializable +final readonly class NotFoundRedirects implements JsonSerializable { private function __construct( - public readonly string|null $baseUrlRedirect, - public readonly string|null $regular404Redirect, - public readonly string|null $invalidShortUrlRedirect, + public string|null $baseUrlRedirect, + public string|null $regular404Redirect, + public string|null $invalidShortUrlRedirect, ) { } @@ -30,7 +30,7 @@ final class NotFoundRedirects implements JsonSerializable public static function fromConfig(NotFoundRedirectConfigInterface $config): self { - return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect()); + return new self($config->baseUrlRedirect, $config->regular404Redirect, $config->invalidShortUrlRedirect); } public function jsonSerialize(): array diff --git a/module/Core/src/Config/Options/NotFoundRedirectOptions.php b/module/Core/src/Config/Options/NotFoundRedirectOptions.php index 7c04d077..07dbbe44 100644 --- a/module/Core/src/Config/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Config/Options/NotFoundRedirectOptions.php @@ -10,48 +10,18 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface { public function __construct( - public string|null $invalidShortUrl = null, - public string|null $regular404 = null, - public string|null $baseUrl = null, + public string|null $invalidShortUrlRedirect = null, + public string|null $regular404Redirect = null, + public string|null $baseUrlRedirect = null, ) { } public static function fromEnv(): self { return new self( - invalidShortUrl: EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(), - regular404: EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(), - baseUrl: EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(), + invalidShortUrlRedirect: EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(), + regular404Redirect: EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(), + baseUrlRedirect: EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(), ); } - - public function invalidShortUrlRedirect(): string|null - { - return $this->invalidShortUrl; - } - - public function hasInvalidShortUrlRedirect(): bool - { - return $this->invalidShortUrl !== null; - } - - public function regular404Redirect(): string|null - { - return $this->regular404; - } - - public function hasRegular404Redirect(): bool - { - return $this->regular404 !== null; - } - - public function baseUrlRedirect(): string|null - { - return $this->baseUrl; - } - - public function hasBaseUrlRedirect(): bool - { - return $this->baseUrl !== null; - } } diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index b55a9dee..835620bf 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -15,9 +15,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec private function __construct( public readonly string $authority, - private string|null $baseUrlRedirect = null, - private string|null $regular404Redirect = null, - private string|null $invalidShortUrlRedirect = null, + private(set) string|null $baseUrlRedirect = null, + private(set) string|null $regular404Redirect = null, + private(set) string|null $invalidShortUrlRedirect = null, ) { } @@ -31,36 +31,6 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->authority; } - public function invalidShortUrlRedirect(): string|null - { - return $this->invalidShortUrlRedirect; - } - - public function hasInvalidShortUrlRedirect(): bool - { - return $this->invalidShortUrlRedirect !== null; - } - - public function regular404Redirect(): string|null - { - return $this->regular404Redirect; - } - - public function hasRegular404Redirect(): bool - { - return $this->regular404Redirect !== null; - } - - public function baseUrlRedirect(): string|null - { - return $this->baseUrlRedirect; - } - - public function hasBaseUrlRedirect(): bool - { - return $this->baseUrlRedirect !== null; - } - public function configureNotFoundRedirects(NotFoundRedirects $redirects): void { $this->baseUrlRedirect = $redirects->baseUrlRedirect; diff --git a/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php b/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php index 52190b4e..76a5d500 100644 --- a/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php +++ b/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php @@ -20,11 +20,8 @@ class EmptyNotFoundRedirectConfigTest extends TestCase #[Test] public function allMethodsReturnHardcodedValues(): void { - self::assertNull($this->redirectsConfig->invalidShortUrlRedirect()); - self::assertFalse($this->redirectsConfig->hasInvalidShortUrlRedirect()); - self::assertNull($this->redirectsConfig->regular404Redirect()); - self::assertFalse($this->redirectsConfig->hasRegular404Redirect()); - self::assertNull($this->redirectsConfig->baseUrlRedirect()); - self::assertFalse($this->redirectsConfig->hasBaseUrlRedirect()); + self::assertNull($this->redirectsConfig->invalidShortUrlRedirect); + self::assertNull($this->redirectsConfig->regular404Redirect); + self::assertNull($this->redirectsConfig->baseUrlRedirect); } } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index 75df0948..d2e750db 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -57,57 +57,57 @@ class NotFoundRedirectResolverTest extends TestCase yield 'base URL with trailing slash' => [ $uri = new Uri('/'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'https://example.com/baseUrl'), + new NotFoundRedirectOptions(baseUrlRedirect: 'https://example.com/baseUrl'), 'https://example.com/baseUrl', ]; yield 'base URL without trailing slash' => [ $uri = new Uri(''), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'https://example.com/baseUrl'), + new NotFoundRedirectOptions(baseUrlRedirect: 'https://example.com/baseUrl'), 'https://example.com/baseUrl', ]; yield 'base URL with domain placeholder' => [ $uri = new Uri('https://s.test'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/{DOMAIN}'), + new NotFoundRedirectOptions(baseUrlRedirect: 'https://redirect-here.com/{DOMAIN}'), 'https://redirect-here.com/s.test', ]; yield 'base URL with domain placeholder in query' => [ $uri = new Uri('https://s.test'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), + new NotFoundRedirectOptions(baseUrlRedirect: 'https://redirect-here.com/?domain={DOMAIN}'), 'https://redirect-here.com/?domain=s.test', ]; yield 'regular 404' => [ $uri = new Uri('/foo/bar'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(regular404: 'https://example.com/regular404'), + new NotFoundRedirectOptions(regular404Redirect: 'https://example.com/regular404'), 'https://example.com/regular404', ]; yield 'regular 404 with path placeholder in query' => [ $uri = new Uri('/foo/bar'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(regular404: 'https://redirect-here.com/?path={ORIGINAL_PATH}'), + new NotFoundRedirectOptions(regular404Redirect: 'https://redirect-here.com/?path={ORIGINAL_PATH}'), 'https://redirect-here.com/?path=%2Ffoo%2Fbar', ]; yield 'regular 404 with multiple placeholders' => [ $uri = new Uri('https://s.test/foo/bar'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions( - regular404: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', + regular404Redirect: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', ), 'https://redirect-here.com/foo/bar/s.test/?d=s.test&p=%2Ffoo%2Fbar', ]; yield 'invalid short URL' => [ new Uri('/foo'), self::notFoundType(self::requestForRoute(RedirectAction::class)), - new NotFoundRedirectOptions(invalidShortUrl: 'https://example.com/invalidShortUrl'), + new NotFoundRedirectOptions(invalidShortUrlRedirect: 'https://example.com/invalidShortUrl'), 'https://example.com/invalidShortUrl', ]; yield 'invalid short URL with path placeholder' => [ new Uri('/foo'), self::notFoundType(self::requestForRoute(RedirectAction::class)), - new NotFoundRedirectOptions(invalidShortUrl: 'https://redirect-here.com/{ORIGINAL_PATH}'), + new NotFoundRedirectOptions(invalidShortUrlRedirect: 'https://redirect-here.com/{ORIGINAL_PATH}'), 'https://redirect-here.com/foo', ]; } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index fb601d51..f4836711 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -181,9 +181,9 @@ class DomainServiceTest extends TestCase if ($foundDomain !== null) { self::assertSame($result, $foundDomain); } - self::assertEquals('foo.com', $result->baseUrlRedirect()); - self::assertEquals('bar.com', $result->regular404Redirect()); - self::assertEquals('baz.com', $result->invalidShortUrlRedirect()); + self::assertEquals('foo.com', $result->baseUrlRedirect); + self::assertEquals('bar.com', $result->regular404Redirect); + self::assertEquals('baz.com', $result->invalidShortUrlRedirect); } public static function provideFoundDomains(): iterable diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php index 0c55f967..b203df31 100644 --- a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -69,11 +69,11 @@ class DomainRedirectsRequest public function toNotFoundRedirects(NotFoundRedirectConfigInterface|null $defaults = null): NotFoundRedirects { return NotFoundRedirects::withRedirects( - $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(), - $this->regular404RedirectWasProvided ? $this->regular404Redirect : $defaults?->regular404Redirect(), + $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect, + $this->regular404RedirectWasProvided ? $this->regular404Redirect : $defaults?->regular404Redirect, $this->invalidShortUrlRedirectWasProvided ? $this->invalidShortUrlRedirect - : $defaults?->invalidShortUrlRedirect(), + : $defaults?->invalidShortUrlRedirect, ); } } diff --git a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php index e203fe11..8c3fab9f 100644 --- a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php +++ b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php @@ -68,13 +68,13 @@ class DomainRedirectsActionTest extends TestCase NotFoundRedirects::withRedirects( array_key_exists(DomainRedirectsInputFilter::BASE_URL_REDIRECT, $redirects) ? $redirects[DomainRedirectsInputFilter::BASE_URL_REDIRECT] - : $domain->baseUrlRedirect(), + : $domain->baseUrlRedirect, array_key_exists(DomainRedirectsInputFilter::REGULAR_404_REDIRECT, $redirects) ? $redirects[DomainRedirectsInputFilter::REGULAR_404_REDIRECT] - : $domain->regular404Redirect(), + : $domain->regular404Redirect, array_key_exists(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT, $redirects) ? $redirects[DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT] - : $domain->invalidShortUrlRedirect(), + : $domain->invalidShortUrlRedirect, ), $apiKey, ); diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php index 45faf9f2..80983064 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -51,7 +51,7 @@ class DomainRedirectsRequestTest extends TestCase yield 'some values' => [['domain' => 'foo', 'regular404Redirect' => 'bar'], null, 'foo', null, 'bar', null]; yield 'fallbacks' => [ ['domain' => 'domain', 'baseUrlRedirect' => 'bar'], - new NotFoundRedirectOptions(invalidShortUrl: 'fallback2', regular404: 'fallback'), + new NotFoundRedirectOptions(invalidShortUrlRedirect: 'fallback2', regular404Redirect: 'fallback'), 'domain', 'bar', 'fallback', @@ -59,7 +59,7 @@ class DomainRedirectsRequestTest extends TestCase ]; yield 'fallback ignored' => [ ['domain' => 'domain', 'regular404Redirect' => 'bar', 'invalidShortUrlRedirect' => null], - new NotFoundRedirectOptions(invalidShortUrl: 'fallback2', regular404: 'fallback'), + new NotFoundRedirectOptions(invalidShortUrlRedirect: 'fallback2', regular404Redirect: 'fallback'), 'domain', null, 'bar', From 0604237b94e46178adbfb26605b377332cfcd14a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 17 Nov 2025 12:12:06 +0100 Subject: [PATCH 14/71] Remove dead code that is affecting code coverage --- composer.json | 4 +-- .../Domain/GetDomainVisitsCommandTest.php | 2 +- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 2 +- .../Command/Tag/GetTagVisitsCommandTest.php | 2 +- .../Visit/GetNonOrphanVisitsCommandTest.php | 2 +- .../Visit/GetOrphanVisitsCommandTest.php | 2 +- .../Command/Visit/LocateVisitsCommandTest.php | 6 ++-- module/Core/src/Spec/InDateRange.php | 33 ------------------- module/Core/src/Visit/Entity/Visit.php | 4 +-- .../Core/src/Visit/Entity/VisitLocation.php | 19 ++--------- .../src/Visit/Geolocation/VisitLocator.php | 2 +- .../VisitIterationRepositoryTest.php | 2 +- .../test/Matomo/MatomoVisitSenderTest.php | 2 +- .../test/Visit/Entity/VisitLocationTest.php | 28 +++++++++++++++- module/Core/test/Visit/Entity/VisitTest.php | 2 +- 15 files changed, 46 insertions(+), 66 deletions(-) delete mode 100644 module/Core/src/Spec/InDateRange.php diff --git a/composer.json b/composer.json index a4669587..78200845 100644 --- a/composer.json +++ b/composer.json @@ -145,12 +145,12 @@ "test:cli:ci": [ "@putenv GENERATE_COVERAGE=yes", "@test:cli", - "vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov" + "@php -d memory_limit=-1 vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov" ], "test:cli:pretty": [ "@putenv GENERATE_COVERAGE=yes", "@test:cli", - "phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov" + "@php -d memory_limit=-1 phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov" ], "openapi:validate": "php-openapi validate docs/swagger/swagger.json", "openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json", diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index e174a3b0..ddf55283 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -41,7 +41,7 @@ class GetDomainVisitsCommandTest extends TestCase { $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $domain = 's.test'; $this->visitsHelper->expects($this->once())->method('visitsForDomain')->with( diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index a1905e38..709974ec 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -94,7 +94,7 @@ class GetShortUrlVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $shortCode = 'abc123'; $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index 08ca2cd3..30580951 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -41,7 +41,7 @@ class GetTagVisitsCommandTest extends TestCase { $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $tag = 'abc123'; $this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn( diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 4ebe780f..f583d063 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -41,7 +41,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase { $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn( new Paginator(new ArrayAdapter([$visit])), diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 33a98448..bf453c65 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -38,7 +38,7 @@ class GetOrphanVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(array $args, bool $includesType): void { $visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback( fn (OrphanVisitsParams $param) => ( diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 1f34baef..ca7db909 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -63,7 +63,7 @@ class LocateVisitsCommandTest extends TestCase array $args, ): void { $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4')); - $location = VisitLocation::fromGeolocation(Location::empty()); + $location = VisitLocation::fromLocation(Location::empty()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); $this->lock->method('acquire')->willReturn(true); @@ -107,7 +107,7 @@ class LocateVisitsCommandTest extends TestCase public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void { $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); - $location = VisitLocation::fromGeolocation(Location::empty()); + $location = VisitLocation::fromLocation(Location::empty()); $this->lock->method('acquire')->willReturn(true); $this->visitService->expects($this->once()) @@ -134,7 +134,7 @@ class LocateVisitsCommandTest extends TestCase public function errorWhileLocatingIpIsDisplayed(): void { $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')); - $location = VisitLocation::fromGeolocation(Location::emptyInstance()); + $location = VisitLocation::fromLocation(Location::empty()); $this->lock->method('acquire')->willReturn(true); $this->visitService->expects($this->once()) diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php deleted file mode 100644 index d373a59d..00000000 --- a/module/Core/src/Spec/InDateRange.php +++ /dev/null @@ -1,33 +0,0 @@ -dateRange?->startDate !== null) { - $criteria[] = Spec::gte($this->field, $this->dateRange->startDate->toDateTimeString()); - } - - if ($this->dateRange?->endDate !== null) { - $criteria[] = Spec::lte($this->field, $this->dateRange->endDate->toDateTimeString()); - } - - return Spec::andX(...$criteria); - } -} diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 70733593..b396bc07 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -70,7 +70,7 @@ class Visit extends AbstractEntity implements JsonSerializable remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, redirectUrl: $visitor->redirectUrl, - visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null, + visitLocation: $geolocation !== null ? VisitLocation::fromLocation($geolocation) : null, ); } @@ -114,7 +114,7 @@ class Visit extends AbstractEntity implements JsonSerializable referer: $importedVisit->referer, potentialBot: isCrawler($importedVisit->userAgent), visitedUrl: $importedVisit instanceof ImportedShlinkOrphanVisit ? $importedVisit->visitedUrl : null, - visitLocation: $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null, + visitLocation: $importedLocation !== null ? VisitLocation::fromLocation($importedLocation) : null, date: normalizeDate($importedVisit->date), ); } diff --git a/module/Core/src/Visit/Entity/VisitLocation.php b/module/Core/src/Visit/Entity/VisitLocation.php index 1f1db686..dcb56698 100644 --- a/module/Core/src/Visit/Entity/VisitLocation.php +++ b/module/Core/src/Visit/Entity/VisitLocation.php @@ -33,29 +33,16 @@ class VisitLocation extends AbstractEntity implements JsonSerializable ); } - public static function fromGeolocation(Location $location): self + public static function fromLocation(Location|ImportedShlinkVisitLocation $location): self { return new self( countryCode: $location->countryCode, countryName: $location->countryName, regionName: $location->regionName, - cityName: $location->city, + cityName: $location instanceof Location ? $location->city : $location->cityName, latitude: $location->latitude, longitude: $location->longitude, - timezone: $location->timeZone, - ); - } - - public static function fromImport(ImportedShlinkVisitLocation $location): self - { - return new self( - countryCode: $location->countryCode, - countryName: $location->countryName, - regionName: $location->regionName, - cityName: $location->cityName, - latitude: $location->latitude, - longitude: $location->longitude, - timezone: $location->timezone, + timezone: $location instanceof Location ? $location->timeZone : $location->timezone, ); } diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index 8f69ba2c..48245a29 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -57,7 +57,7 @@ readonly class VisitLocator implements VisitLocatorInterface $location = Location::empty(); } - $this->locateVisit($visit, VisitLocation::fromGeolocation($location), $helper); + $this->locateVisit($visit, VisitLocation::fromLocation($location), $helper); // Flush and clear after X iterations if ($count % $persistBlock === 0) { diff --git a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php index 60c2fbea..88f273c2 100644 --- a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -40,7 +40,7 @@ class VisitIterationRepositoryTest extends DatabaseTestCase $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); if ($i >= 2) { - $location = VisitLocation::fromGeolocation(Location::emptyInstance()); + $location = VisitLocation::fromLocation(Location::empty()); $this->getEntityManager()->persist($location); $visit->locate($location); } diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 1b1b303c..0acc535b 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -87,7 +87,7 @@ class MatomoVisitSenderTest extends TestCase yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::empty()), null, []]; yield 'located regular visit' => [ Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::empty()) - ->locate(VisitLocation::fromGeolocation(new Location( + ->locate(VisitLocation::fromLocation(new Location( countryCode: 'US', countryName: 'countryName', regionName: 'regionName', diff --git a/module/Core/test/Visit/Entity/VisitLocationTest.php b/module/Core/test/Visit/Entity/VisitLocationTest.php index 5f5c458d..791eabe9 100644 --- a/module/Core/test/Visit/Entity/VisitLocationTest.php +++ b/module/Core/test/Visit/Entity/VisitLocationTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocationTest extends TestCase @@ -16,7 +17,7 @@ class VisitLocationTest extends TestCase public function isEmptyReturnsTrueWhenAllValuesAreEmpty(array $args, bool $isEmpty): void { $payload = new Location(...$args); - $location = VisitLocation::fromGeolocation($payload); + $location = VisitLocation::fromLocation($payload); self::assertEquals($isEmpty, $location->isEmpty); } @@ -32,4 +33,29 @@ class VisitLocationTest extends TestCase yield [['', '', '', '', 1.0, 0.0, ''], false]; yield [['', '', '', '', 0.0, 1.0, ''], false]; } + + #[Test] + public function jsonSerialization(): void + { + $location = VisitLocation::fromLocation(new ImportedShlinkVisitLocation( + countryCode: 'countryCode', + countryName: 'countryName', + regionName: 'regionName', + cityName: 'cityName', + timezone: 'timezone', + latitude: 1, + longitude: 2, + )); + + self::assertEquals([ + 'countryCode' => $location->countryCode, + 'countryName' => $location->countryName, + 'regionName' => $location->regionName, + 'cityName' => $location->cityName, + 'latitude' => $location->latitude, + 'longitude' => $location->longitude, + 'timezone' => $location->timezone, + 'isEmpty' => $location->isEmpty, + ], $location->jsonSerialize()); + } } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 438ca55f..14f36551 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -95,7 +95,7 @@ class VisitTest extends TestCase ->withHeader('Referer', 'referer') ->withUri(new Uri('https://s.test/foo/bar')), ), - )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), + )->locate($location = VisitLocation::fromLocation(Location::empty())), [ 'referer' => 'referer', 'date' => $visit->date->toAtomString(), From f3ff059d486380f0c3b731da54ae5172aad35778 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 17 Nov 2025 12:33:08 +0100 Subject: [PATCH 15/71] Improve RoleResolver coverage --- module/CLI/test/ApiKey/RoleResolverTest.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index 184780d7..ac47a262 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -91,11 +91,17 @@ class RoleResolverTest extends TestCase [RoleDefinition::forAuthoredShortUrls()], 0, ]; - yield 'both roles' => [ - $buildInput( - [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true], - ), - [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)], + yield 'all roles' => [ + $buildInput([ + Role::DOMAIN_SPECIFIC->paramName() => 'example.com', + Role::AUTHORED_SHORT_URLS->paramName() => true, + Role::NO_ORPHAN_VISITS->paramName() => true, + ]), + [ + RoleDefinition::forAuthoredShortUrls(), + RoleDefinition::forDomain($domain), + RoleDefinition::forNoOrphanVisits(), + ], 1, ]; } From 933c54e884ed977b755ee4fed214ccf7db16ae5c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Nov 2025 08:41:42 +0100 Subject: [PATCH 16/71] Improve some console commands coverage --- .../ShortUrl/CreateShortUrlCommandTest.php | 15 +++++++++++++++ .../ShortUrl/GetShortUrlVisitsCommandTest.php | 13 +++++++++++++ .../Command/ShortUrl/ResolveUrlCommandTest.php | 13 +++++++++++++ 3 files changed, 41 insertions(+) diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index ff5e3ea6..9452aad5 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -64,6 +64,21 @@ class CreateShortUrlCommandTest extends TestCase self::assertStringNotContainsString('but the real-time updates cannot', $output); } + #[Test] + public function longUrlIsAskedIfNotProvided(): void + { + $shortUrl = ShortUrl::createFake(); + $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn( + UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl), + ); + $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( + 'stringified_short_url', + ); + + $this->commandTester->setInputs([$shortUrl->getLongUrl()]); + $this->commandTester->execute([]); + } + #[Test] public function providingNonUniqueSlugOutputsError(): void { diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 709974ec..e306b0bc 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -50,6 +50,19 @@ class GetShortUrlVisitsCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); } + #[Test] + public function shortCodeIsAskedIfNotProvided(): void + { + $shortCode = 'abc123'; + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + $this->anything(), + )->willReturn(new Paginator(new ArrayAdapter([]))); + + $this->commandTester->setInputs([$shortCode]); + $this->commandTester->execute([]); + } + #[Test] public function providingDateFlagsTheListGetsFiltered(): void { diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 9c9bbb93..742ae05c 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -45,6 +45,19 @@ class ResolveUrlCommandTest extends TestCase self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output); } + #[Test] + public function shortCodeIsAskedIfNotProvided(): void + { + $shortCode = 'abc123'; + $shortUrl = ShortUrl::createFake(); + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + )->willReturn($shortUrl); + + $this->commandTester->setInputs([$shortCode]); + $this->commandTester->execute([]); + } + #[Test] public function incorrectShortCodeOutputsErrorMessage(): void { From db1411d3f8163a9c527cd5ad0a0aef250d249e90 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Nov 2025 08:45:31 +0100 Subject: [PATCH 17/71] Remove unused method in ApiKeyNotFoundException --- module/Rest/src/Exception/ApiKeyNotFoundException.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/module/Rest/src/Exception/ApiKeyNotFoundException.php b/module/Rest/src/Exception/ApiKeyNotFoundException.php index 5519b31a..d7b76d33 100644 --- a/module/Rest/src/Exception/ApiKeyNotFoundException.php +++ b/module/Rest/src/Exception/ApiKeyNotFoundException.php @@ -12,10 +12,4 @@ class ApiKeyNotFoundException extends RuntimeException implements ExceptionInter { return new self(sprintf('API key with name "%s" not found', $name)); } - - /** @deprecated */ - public static function forKey(string $key): self - { - return new self(sprintf('API key with key "%s" not found', $key)); - } } From 88e5bb5618facc4979a4c975df25eb06c12b0d71 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Nov 2025 08:56:09 +0100 Subject: [PATCH 18/71] Add test for AbstractRestAction::getRouteDef() --- module/Rest/test/Action/MercureInfoActionTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index 69bfb56a..ddd43338 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -77,4 +77,15 @@ class MercureInfoActionTest extends TestCase yield 'days not defined' => [null]; yield 'days defined' => [10]; } + + #[Test] + public function getRouteDefReturnsExpectedData(): void + { + self::assertEquals([ + 'name' => MercureInfoAction::class, + 'middleware' => [MercureInfoAction::class], + 'path' => '/mercure-info', + 'allowed_methods' => ['GET'], + ], MercureInfoAction::getRouteDef()); + } } From 1e0b6be67da1b024ea978348adf93660d3e8fb88 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Nov 2025 09:06:11 +0100 Subject: [PATCH 19/71] Improved NorFoundRedirectResolver test --- .../Config/NotFoundRedirectResolverTest.php | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index d2e750db..796cc3bb 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -15,7 +15,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; -use Psr\Log\NullLogger; +use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolver; use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions; @@ -28,11 +28,14 @@ class NotFoundRedirectResolverTest extends TestCase { private NotFoundRedirectResolver $resolver; private MockObject & RedirectResponseHelperInterface $helper; + private MockObject & LoggerInterface $logger; protected function setUp(): void { $this->helper = $this->createMock(RedirectResponseHelperInterface::class); - $this->resolver = new NotFoundRedirectResolver($this->helper, new NullLogger()); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->resolver = new NotFoundRedirectResolver($this->helper, $this->logger); } #[Test, DataProvider('provideRedirects')] @@ -123,6 +126,22 @@ class NotFoundRedirectResolverTest extends TestCase self::assertNull($result); } + #[Test] + public function warningMessageIsLoggedIfRedirectUrlIsMalformed(): void + { + $this->logger->expects($this->once())->method('warning')->with( + 'It was not possible to parse "{url}" as a valid URL: {e}', + $this->isArray(), + ); + + $uri = new Uri('/'); + $this->resolver->resolveRedirectResponse( + self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), + new NotFoundRedirectOptions(baseUrlRedirect: 'http:///example.com'), + $uri, + ); + } + private static function notFoundType(ServerRequestInterface $req): NotFoundType { return NotFoundType::fromRequest($req, ''); From 7812a85b3986d52973bebb40fe31d0bb7c872c6b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Nov 2025 09:20:52 +0100 Subject: [PATCH 20/71] Remove unused AppOptions::__toString method --- module/Core/src/Config/Options/AppOptions.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/module/Core/src/Config/Options/AppOptions.php b/module/Core/src/Config/Options/AppOptions.php index 71e3f507..45577e98 100644 --- a/module/Core/src/Config/Options/AppOptions.php +++ b/module/Core/src/Config/Options/AppOptions.php @@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\Core\Config\Options; use Shlinkio\Shlink\Core\Config\EnvVars; -use function sprintf; - final class AppOptions { public function __construct(public string $name = 'Shlink', public string $version = '4.0.0') @@ -19,9 +17,4 @@ final class AppOptions $version = EnvVars::isDevEnv() ? 'latest' : '%SHLINK_VERSION%'; return new self(version: $version); } - - public function __toString(): string - { - return sprintf('%s:v%s', $this->name, $this->version); - } } From 9432a5ba78c7efc73b8c5e32174de7c540be6184 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Nov 2025 09:30:30 +0100 Subject: [PATCH 21/71] Add tests for events --- .../EventDispatcher/Event/ShortUrlCreated.php | 4 +-- .../Event/ShortUrlCreatedTest.php | 29 +++++++++++++++ .../EventDispatcher/Event/UrlVisitedTest.php | 35 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 module/Core/test/EventDispatcher/Event/ShortUrlCreatedTest.php create mode 100644 module/Core/test/EventDispatcher/Event/UrlVisitedTest.php diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php index 4055935f..0d929ec7 100644 --- a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -15,9 +15,7 @@ final readonly class ShortUrlCreated implements JsonSerializable, JsonUnserializ public function jsonSerialize(): array { - return [ - 'shortUrlId' => $this->shortUrlId, - ]; + return ['shortUrlId' => $this->shortUrlId]; } public static function fromPayload(array $payload): self diff --git a/module/Core/test/EventDispatcher/Event/ShortUrlCreatedTest.php b/module/Core/test/EventDispatcher/Event/ShortUrlCreatedTest.php new file mode 100644 index 00000000..4b59f2e4 --- /dev/null +++ b/module/Core/test/EventDispatcher/Event/ShortUrlCreatedTest.php @@ -0,0 +1,29 @@ + $shortUrlId], new ShortUrlCreated($shortUrlId)->jsonSerialize()); + } + + #[Test] + #[TestWith([['shortUrlId' => '123'], '123'])] + #[TestWith([[], ''])] + public function creationFromPayload(array $payload, string $expectedShortUrlId): void + { + $event = ShortUrlCreated::fromPayload($payload); + self::assertEquals($expectedShortUrlId, $event->shortUrlId); + } +} diff --git a/module/Core/test/EventDispatcher/Event/UrlVisitedTest.php b/module/Core/test/EventDispatcher/Event/UrlVisitedTest.php new file mode 100644 index 00000000..ebcfa265 --- /dev/null +++ b/module/Core/test/EventDispatcher/Event/UrlVisitedTest.php @@ -0,0 +1,35 @@ + $visitId, 'originalIpAddress' => null], + new UrlVisited($visitId)->jsonSerialize(), + ); + } + + #[Test] + #[TestWith([['visitId' => '123', 'originalIpAddress' => '1.2.3.4'], '123', '1.2.3.4'])] + #[TestWith([['visitId' => '123'], '123', null])] + #[TestWith([['originalIpAddress' => '1.2.3.4'], '', '1.2.3.4'])] + #[TestWith([[], '', null])] + public function creationFromPayload(array $payload, string $expectedVisitId, string|null $expectedIpAddress): void + { + $event = UrlVisited::fromPayload($payload); + self::assertEquals($expectedVisitId, $event->visitId); + self::assertEquals($expectedIpAddress, $event->originalIpAddress); + } +} From 1996745f64f3e715b05b48dc268cda345c117132 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 2 Dec 2025 12:11:56 +0100 Subject: [PATCH 22/71] Update to Symfony 8.0 --- CHANGELOG.md | 2 ++ composer.json | 28 +++++++++---------- .../MatomoSendVisitsCommandTest.php | 1 + .../ShortUrl/DeleteShortUrlCommandTest.php | 3 ++ .../DeleteShortUrlVisitsCommandTest.php | 3 ++ .../Command/Visit/LocateVisitsCommandTest.php | 5 +++- module/CLI/test/Util/CliTestUtils.php | 4 +-- .../LocateUnlocatedVisitsTest.php | 2 +- .../Visit/Geolocation/VisitLocatorTest.php | 2 +- 9 files changed, 31 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c97a1fa..9da4fd91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink. +* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0. + ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 78200845..8755c856 100644 --- a/composer.json +++ b/composer.json @@ -41,22 +41,22 @@ "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.2", - "shlinkio/shlink-common": "^7.2", - "shlinkio/shlink-config": "^4.0", - "shlinkio/shlink-event-dispatcher": "^4.3", - "shlinkio/shlink-importer": "^5.6", - "shlinkio/shlink-installer": "dev-develop#2b9e6bd as 10.0.0", - "shlinkio/shlink-ip-geolocation": "^4.4", - "shlinkio/shlink-json": "^1.2", + "shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.0", + "shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0", + "shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0", + "shlinkio/shlink-importer": "dev-main#4498f0a as 5.7.0", + "shlinkio/shlink-installer": "dev-develop#40e08cb as 10.0.0", + "shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0", + "shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0", "spiral/roadrunner": "^2025.1", "spiral/roadrunner-cli": "^2.7", "spiral/roadrunner-http": "^3.5", "spiral/roadrunner-jobs": "^4.6", - "symfony/console": "^7.3", - "symfony/filesystem": "^7.3", - "symfony/lock": "^7.3.2", - "symfony/process": "^7.3", - "symfony/string": "^7.3" + "symfony/console": "^8.0 || ^7.4", + "symfony/filesystem": "^8.0", + "symfony/lock": "^8.0", + "symfony/process": "^8.0", + "symfony/string": "^8.0" }, "require-dev": { "devizzent/cebe-php-openapi": "^1.1.2", @@ -70,8 +70,8 @@ "phpunit/phpunit": "^12.0.10", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.5.0", - "shlinkio/shlink-test-utils": "^4.3.1", - "symfony/var-dumper": "^7.3", + "shlinkio/shlink-test-utils": "^4.4", + "symfony/var-dumper": "^8.0", "veewee/composer-run-parallel": "^1.4" }, "conflict": { diff --git a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php index f4eebdf5..dadce789 100644 --- a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php +++ b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php @@ -114,6 +114,7 @@ class MatomoSendVisitsCommandTest extends TestCase } /** + * @param list $input * @return array{string, int, MatomoSendVisitsCommand} */ private function executeCommand( diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 6ffec859..62872123 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -64,6 +64,9 @@ class DeleteShortUrlCommandTest extends TestCase self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); } + /** + * @param list $retryAnswer + */ #[Test, DataProvider('provideRetryDeleteAnswers')] public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted( array $retryAnswer, diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php index de038aef..3efa94b5 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php @@ -27,6 +27,9 @@ class DeleteShortUrlVisitsCommandTest extends TestCase $this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter)); } + /** + * @param list $input + */ #[Test, DataProvider('provideCancellingInputs')] public function executionIsAbortedIfManuallyCancelled(array $input): void { diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index ca7db909..2c82247f 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -81,7 +81,7 @@ class LocateVisitsCommandTest extends TestCase ->willReturnCallback($mockMethodBehavior); $this->visitToLocation->expects( $this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls), - )->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance()); + )->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::empty()); $this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS); $this->commandTester->setInputs(['y']); @@ -204,6 +204,9 @@ class LocateVisitsCommandTest extends TestCase self::assertStringContainsString('The --all flag has no effect on its own', $output); } + /** + * @param list $inputs + */ #[Test, DataProvider('provideAbortInputs')] public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void { diff --git a/module/CLI/test/Util/CliTestUtils.php b/module/CLI/test/Util/CliTestUtils.php index 5f94c661..bacde361 100644 --- a/module/CLI/test/Util/CliTestUtils.php +++ b/module/CLI/test/Util/CliTestUtils.php @@ -40,9 +40,9 @@ class CliTestUtils public static function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester { $app = new Application(); - $app->add($mainCommand); + $app->addCommand($mainCommand); foreach ($extraCommands as $command) { - $app->add($command); + $app->addCommand($command); } return new CommandTester($mainCommand); diff --git a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php index ef776d42..d8168f6e 100644 --- a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -40,7 +40,7 @@ class LocateUnlocatedVisitsTest extends TestCase public function visitToLocationHelperIsCalledToGeolocateVisits(): void { $visit = Visit::forBasePath(Visitor::empty()); - $location = Location::emptyInstance(); + $location = Location::empty(); $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->with($visit)->willReturn( $location, diff --git a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php index 68c746d7..0d51b402 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -67,7 +67,7 @@ class VisitLocatorTest extends TestCase $this->visitService->{$serviceMethodName}(new class implements VisitGeolocationHelperInterface { public function geolocateVisit(Visit $visit): Location { - return Location::emptyInstance(); + return Location::empty(); } public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void From a75ee138e177c917df47d202ce017fc839bbbf8d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 11:05:20 +0100 Subject: [PATCH 23/71] Migrate ListShortUrlsCommand to symfony/console attributes --- .../ShortUrl/Input/ShortUrlsParamsInput.php | 116 +++++++++++++ .../Command/ShortUrl/ListShortUrlsCommand.php | 159 ++---------------- module/CLI/src/Input/DateOption.php | 3 +- module/CLI/src/Input/InputUtils.php | 37 ++++ .../ShortUrl/ListShortUrlsCommandTest.php | 2 - module/CLI/test/Input/InputUtilsTest.php | 47 ++++++ 6 files changed, 214 insertions(+), 150 deletions(-) create mode 100644 module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php create mode 100644 module/CLI/src/Input/InputUtils.php create mode 100644 module/CLI/test/Input/InputUtilsTest.php diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php new file mode 100644 index 00000000..29c185d0 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php @@ -0,0 +1,116 @@ +tagsAll ? TagsMode::ALL->value : TagsMode::ANY->value; + $excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL->value : TagsMode::ANY->value; + + $data = [ + ShortUrlsParamsInputFilter::PAGE => $this->page, + ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm, + ShortUrlsParamsInputFilter::DOMAIN => $this->domain, + ShortUrlsParamsInputFilter::TAGS => array_unique($this->tags), + ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, + ShortUrlsParamsInputFilter::EXCLUDE_TAGS => array_unique($this->excludeTags), + ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode, + ShortUrlsParamsInputFilter::ORDER_BY => $this->orderBy, + ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output), + ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output), + ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $this->excludeMaxVisitsReached, + ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $this->excludePastValidUntil, + ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName, + ]; + + if ($this->all) { + $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; + } + + return $data; + } +} diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 99c445f0..2882c839 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Input\EndDateOption; -use Shlinkio\Shlink\CLI\Input\StartDateOption; -use Shlinkio\Shlink\CLI\Input\TagsOption; +use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlsParamsInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; @@ -14,165 +12,43 @@ 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\ShortUrlWithDeps; -use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; -use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function array_keys; -use function array_pad; -use function explode; use function implode; use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; +#[AsCommand(name: ListShortUrlsCommand::NAME, description: 'List all short URLs')] class ListShortUrlsCommand extends Command { public const string NAME = 'short-url:list'; - private readonly StartDateOption $startDateOption; - private readonly EndDateOption $endDateOption; - private readonly TagsOption $tagsOption; - public function __construct( private readonly ShortUrlListServiceInterface $shortUrlService, private readonly ShortUrlDataTransformerInterface $transformer, ) { parent::__construct(); - $this->startDateOption = new StartDateOption($this, 'short URLs'); - $this->endDateOption = new EndDateOption($this, 'short URLs'); - $this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.'); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('List all short URLs') - ->addOption( - 'page', - 'p', - InputOption::VALUE_REQUIRED, - 'The first page to list (10 items per page unless "--all" is provided).', - '1', - ) - ->addOption( - 'search-term', - 'st', - 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-all', - mode: InputOption::VALUE_NONE, - description: 'If --tag is provided, returns only short URLs including ALL of them', - ) - ->addOption( - 'exclude-tag', - 'et', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'A list of tags that short URLs should not have.', - ) - ->addOption( - 'exclude-tags-all', - mode: InputOption::VALUE_NONE, - description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them', - ) - ->addOption( - 'exclude-max-visits-reached', - null, - InputOption::VALUE_NONE, - 'Excludes short URLs which reached their max amount of visits.', - ) - ->addOption( - 'exclude-past-valid-until', - null, - InputOption::VALUE_NONE, - 'Excludes short URLs which have a "validUntil" date in the past.', - ) - ->addOption( - 'order-by', - 'o', - InputOption::VALUE_REQUIRED, - 'The field from which you want to order by. ' - . 'Define ordering dir by passing ASC or DESC after "-" or ",".', - ) - ->addOption( - 'api-key-name', - 'kn', - InputOption::VALUE_REQUIRED, - 'List only short URLs created by the API key matching provided name.', - ) - ->addOption( - 'show-tags', - null, - InputOption::VALUE_NONE, - 'Whether to display the tags or not.', - ) - ->addOption( - 'show-domain', - null, - InputOption::VALUE_NONE, - 'Whether to display the domain or not. Those belonging to default domain will have value "DEFAULT".', - ) - ->addOption( - 'show-api-key', - 'k', - InputOption::VALUE_NONE, - 'Whether to display the API key name from which the URL was generated or not.', - ) - ->addOption( - 'all', - 'a', - InputOption::VALUE_NONE, - 'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,' - . ' this may end up failing due to memory usage.', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $page = (int) $input->getOption('page'); - $tagsMode = $input->getOption('tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; - $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; - - $data = [ - ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'), - ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'), - ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input), - ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, - ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'), - ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode, - ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input), - ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(), - ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(), - ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'), - ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'), - ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'), - ]; - - $all = $input->getOption('all'); - if ($all) { - $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; - } + public function __invoke( + SymfonyStyle $io, + InputInterface $input, + #[MapInput] ShortUrlsParamsInput $paramsInput, + ): int { + $page = $paramsInput->page; + $data = $paramsInput->toArray($io); $columnsMap = $this->resolveColumnsMap($input); do { - $data[ShortUrlsParamsInputFilter::PAGE] = $page; - $result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all); + $result = $this->renderPage($io, $columnsMap, ShortUrlsParams::fromRawData($data), $paramsInput->all); $page++; $continue = $result->hasNextPage() && $io->confirm( @@ -213,17 +89,6 @@ class ListShortUrlsCommand extends Command return $shortUrls; } - private function processOrderBy(InputInterface $input): string|null - { - $orderBy = $input->getOption('order-by'); - if (empty($orderBy)) { - return null; - } - - [$field, $dir] = array_pad(explode(',', $orderBy), 2, null); - return $dir === null ? $field : sprintf('%s-%s', $field, $dir); - } - /** * @return array */ diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php index 74acc162..05c9de94 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; use function is_string; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function sprintf; readonly class DateOption @@ -29,7 +30,7 @@ readonly class DateOption } try { - return Chronos::parse($value); + return normalizeOptionalDate($value); } catch (Throwable $e) { $output->writeln(sprintf( '> Ignored provided "%s" since its value "%s" is not a valid date. <', diff --git a/module/CLI/src/Input/InputUtils.php b/module/CLI/src/Input/InputUtils.php new file mode 100644 index 00000000..8a719bde --- /dev/null +++ b/module/CLI/src/Input/InputUtils.php @@ -0,0 +1,37 @@ +toAtomString(); + } catch (Throwable) { + $output->writeln(sprintf( + '> Ignored provided "%s" since its value "%s" is not a valid date. <', + $name, + $value, + )); + + return null; + } + } +} diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 76d1882f..69190f7c 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -306,8 +306,6 @@ class ListShortUrlsCommandTest extends TestCase { yield [[], null]; yield [['--order-by' => 'visits'], 'visits']; - yield [['--order-by' => 'longUrl,ASC'], 'longUrl-ASC']; - yield [['--order-by' => 'shortCode,DESC'], 'shortCode-DESC']; yield [['--order-by' => 'title-DESC'], 'title-DESC']; } diff --git a/module/CLI/test/Input/InputUtilsTest.php b/module/CLI/test/Input/InputUtilsTest.php new file mode 100644 index 00000000..beb882ca --- /dev/null +++ b/module/CLI/test/Input/InputUtilsTest.php @@ -0,0 +1,47 @@ +input = $this->createMock(OutputInterface::class); + } + + #[Test] + #[TestWith([null], 'null')] + #[TestWith([''], 'empty string')] + public function processDateReturnsNullForEmptyDates(string|null $date): void + { + self::assertNull(InputUtils::processDate('name', $date, $this->input)); + } + + #[Test] + public function processDateReturnsAtomFormatedForValidDates(): void + { + $date = '2025-01-20'; + self::assertEquals(Chronos::parse($date)->toAtomString(), InputUtils::processDate('name', $date, $this->input)); + } + + #[Test] + public function warningIsPrintedWhenDateIsInvalid(): void + { + $this->input->expects($this->once())->method('writeln')->with( + '> Ignored provided "name" since its value "invalid" is not a valid date. <', + ); + self::assertNull(InputUtils::processDate('name', 'invalid', $this->input)); + } +} From 5600c1cc4f79fdeaf7cdcc89ebc9f03de7f23be2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 11:07:25 +0100 Subject: [PATCH 24/71] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da4fd91..cf4c36b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name. * [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` deprecated options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead. * [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag. +* [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead. ### Fixed * *Nothing* From 309ef8dc95d450df65a7f81fdcef1ad465f0f1ae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 11:10:42 +0100 Subject: [PATCH 25/71] Fix pagination in short-url:list command --- module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 2882c839..fd2d552a 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -12,6 +12,7 @@ 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\ShortUrlWithDeps; +use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -48,6 +49,7 @@ class ListShortUrlsCommand extends Command $columnsMap = $this->resolveColumnsMap($input); do { + $data[ShortUrlsParamsInputFilter::PAGE] = $page; $result = $this->renderPage($io, $columnsMap, ShortUrlsParams::fromRawData($data), $paramsInput->all); $page++; From 5df3abbce9ff42bb4caa9eb2b0bcf8aa690196fa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 16:49:52 +0100 Subject: [PATCH 26/71] Migrate GenerateKeyCommand to symfony/console attributes --- module/CLI/src/ApiKey/RoleResolver.php | 16 ++-- .../CLI/src/ApiKey/RoleResolverInterface.php | 5 +- .../src/Command/Api/GenerateKeyCommand.php | 95 +++++-------------- .../CLI/src/Command/Api/Input/ApiKeyInput.php | 33 +++++++ module/CLI/test/ApiKey/RoleResolverTest.php | 60 +++++------- module/Rest/src/ApiKey/Role.php | 10 +- 6 files changed, 92 insertions(+), 127 deletions(-) create mode 100644 module/CLI/src/Command/Api/Input/ApiKeyInput.php diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index ece56c77..00c66e7c 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -4,15 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\ApiKey; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Role; -use Symfony\Component\Console\Input\InputInterface; - -use function is_string; +/** @deprecated API key roles are deprecated */ readonly class RoleResolver implements RoleResolverInterface { public function __construct( @@ -21,16 +19,16 @@ readonly class RoleResolver implements RoleResolverInterface ) { } - public function determineRoles(InputInterface $input): iterable + public function determineRoles(ApiKeyInput $input): iterable { - $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); - $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); - $noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName()); + $domainAuthority = $input->domain; + $author = $input->authorOnly; + $noOrphanVisits = $input->noOrphanVisits; if ($author) { yield RoleDefinition::forAuthoredShortUrls(); } - if (is_string($domainAuthority)) { + if ($domainAuthority !== null) { yield $this->resolveRoleForAuthority($domainAuthority); } if ($noOrphanVisits) { diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php index e849ad13..6f90843b 100644 --- a/module/CLI/src/ApiKey/RoleResolverInterface.php +++ b/module/CLI/src/ApiKey/RoleResolverInterface.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\ApiKey; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Symfony\Component\Console\Input\InputInterface; +/** @deprecated API key roles are deprecated */ interface RoleResolverInterface { /** * @return iterable */ - public function determineRoles(InputInterface $input): iterable; + public function determineRoles(ApiKeyInput $input): iterable; } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 0c4afcd0..b9535b94 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -4,40 +4,27 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -use Cake\Chronos\Chronos; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; use Symfony\Component\Console\Command\Command; 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\arrayToString; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function sprintf; -class GenerateKeyCommand extends Command -{ - public const string NAME = 'api-key:generate'; - - public function __construct( - private readonly ApiKeyServiceInterface $apiKeyService, - private readonly RoleResolverInterface $roleResolver, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); - $domainOnly = Role::DOMAIN_SPECIFIC->paramName(); - $noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName(); - - $help = <<%command.name% generates a new valid API key. %command.full_name% @@ -49,62 +36,26 @@ class GenerateKeyCommand extends Command You can optionally set its expiration date with --expiration-date or -e: %command.full_name% --expiration-date 2020-01-01 + HELP, +)] +class GenerateKeyCommand extends Command +{ + public const string NAME = 'api-key:generate'; - You can also set roles to the API key: - - * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly} - * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com - * Cannot see orphan visits: %command.full_name% --{$noOrphanVisits} - * All: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits} - HELP; - - $this - ->setName(self::NAME) - ->setDescription('Generate a new valid API key.') - ->addOption( - 'name', - 'm', - InputOption::VALUE_REQUIRED, - 'The name by which this API key will be known.', - ) - ->addOption( - 'expiration-date', - 'e', - InputOption::VALUE_REQUIRED, - 'The date in which the API key should expire. Use any valid PHP format.', - ) - ->addOption( - $authorOnly, - 'a', - InputOption::VALUE_NONE, - sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value), - ) - ->addOption( - $domainOnly, - 'd', - InputOption::VALUE_REQUIRED, - sprintf( - 'Adds the "%s" role to the new API key, with the domain provided.', - Role::DOMAIN_SPECIFIC->value, - ), - ) - ->addOption( - $noOrphanVisits, - 'o', - InputOption::VALUE_NONE, - sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value), - ) - ->setHelp($help); + public function __construct( + private readonly ApiKeyServiceInterface $apiKeyService, + private readonly RoleResolverInterface $roleResolver, + ) { + parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io, InputInterface $input, #[MapInput] ApiKeyInput $inputData): int { - $io = new SymfonyStyle($input, $output); - $expirationDate = $input->getOption('expiration-date'); + $expirationDate = $inputData->expirationDate; $apiKeyMeta = ApiKeyMeta::fromParams( - name: $input->getOption('name'), - expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null, - roleDefinitions: $this->roleResolver->determineRoles($input), + name: $inputData->name, + expirationDate: isset($expirationDate) ? normalizeOptionalDate($expirationDate) : null, + roleDefinitions: $this->roleResolver->determineRoles($inputData), ); $apiKey = $this->apiKeyService->create($apiKeyMeta); diff --git a/module/CLI/src/Command/Api/Input/ApiKeyInput.php b/module/CLI/src/Command/Api/Input/ApiKeyInput.php new file mode 100644 index 00000000..ae19efc7 --- /dev/null +++ b/module/CLI/src/Command/Api/Input/ApiKeyInput.php @@ -0,0 +1,33 @@ +value . '" role to the new API key', shortcut: 'a')] + public bool $authorOnly = false; + + /** @deprecated */ + #[Option( + 'Adds the "' . Role::DOMAIN_SPECIFIC->value . '" role to the new API key, with provided domain', + name: 'domain-only', + shortcut: 'd', + )] + public string|null $domain = null; + + /** @deprecated */ + #[Option('Adds the "' . Role::NO_ORPHAN_VISITS->value . '" role to the new API key', shortcut: 'o')] + public bool $noOrphanVisits = false; +} diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index ac47a262..28d194c3 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -9,12 +9,12 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\ApiKey\RoleResolver; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Role; use Symfony\Component\Console\Input\InputInterface; class RoleResolverTest extends TestCase @@ -30,11 +30,10 @@ class RoleResolverTest extends TestCase #[Test, DataProvider('provideRoles')] public function properRolesAreResolvedBasedOnInput( - callable $createInput, + ApiKeyInput $input, array $expectedRoles, int $expectedDomainCalls, ): void { - $input = $createInput($this); $this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with( 'example.com', )->willReturn(self::domainWithId(Domain::withAuthority('example.com'))); @@ -60,43 +59,39 @@ class RoleResolverTest extends TestCase }; yield 'no roles' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]), + new ApiKeyInput(), [], 0, ]; yield 'domain role only' => [ - $buildInput( - [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false], - ), + (function (): ApiKeyInput { + $input = new ApiKeyInput(); + $input->domain = 'example.com'; + + return $input; + })(), [RoleDefinition::forDomain($domain)], 1, ]; - yield 'false domain role' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]), - [], - 0, - ]; - yield 'true domain role' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]), - [], - 0, - ]; - yield 'string array domain role' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]), - [], - 0, - ]; yield 'author role only' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]), + (function (): ApiKeyInput { + $input = new ApiKeyInput(); + $input->authorOnly = true; + + return $input; + })(), [RoleDefinition::forAuthoredShortUrls()], 0, ]; yield 'all roles' => [ - $buildInput([ - Role::DOMAIN_SPECIFIC->paramName() => 'example.com', - Role::AUTHORED_SHORT_URLS->paramName() => true, - Role::NO_ORPHAN_VISITS->paramName() => true, - ]), + (function (): ApiKeyInput { + $input = new ApiKeyInput(); + $input->domain = 'example.com'; + $input->authorOnly = true; + $input->noOrphanVisits = true; + + return $input; + })(), [ RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain), @@ -109,13 +104,8 @@ class RoleResolverTest extends TestCase #[Test] public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void { - $input = $this->createStub(InputInterface::class); - $input - ->method('getOption') - ->willReturnMap([ - [Role::DOMAIN_SPECIFIC->paramName(), 'default.com'], - [Role::AUTHORED_SHORT_URLS->paramName(), null], - ]); + $input = new ApiKeyInput(); + $input->domain = 'default.com'; $this->expectException(InvalidRoleConfigException::class); diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 7cca292d..7668e285 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; use function sprintf; +/** @deprecated API key roles are deprecated */ enum Role: string { case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; @@ -29,15 +30,6 @@ enum Role: string }; } - public function paramName(): string - { - return match ($this) { - self::AUTHORED_SHORT_URLS => 'author-only', - self::DOMAIN_SPECIFIC => 'domain-only', - self::NO_ORPHAN_VISITS => 'no-orphan-visits', - }; - } - public static function toSpec(ApiKeyRole $role, string|null $context = null): Specification { return match ($role->role) { From 5a390894ea6db80dd2b66926056a7750813ca4d6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 16:55:31 +0100 Subject: [PATCH 27/71] Use Ask attribute to simplify RenameApiKeyCommand --- .../src/Command/Api/RenameApiKeyCommand.php | 41 +++---------------- .../Command/Api/RenameApiKeyCommandTest.php | 9 ---- 2 files changed, 5 insertions(+), 45 deletions(-) diff --git a/module/CLI/src/Command/Api/RenameApiKeyCommand.php b/module/CLI/src/Command/Api/RenameApiKeyCommand.php index fcbca1ce..fc0ec9bb 100644 --- a/module/CLI/src/Command/Api/RenameApiKeyCommand.php +++ b/module/CLI/src/Command/Api/RenameApiKeyCommand.php @@ -4,19 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -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\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use function Shlinkio\Shlink\Core\ArrayUtils\map; - #[AsCommand( name: RenameApiKeyCommand::NAME, description: 'Renames an API key by name', @@ -30,38 +25,12 @@ class RenameApiKeyCommand extends Command parent::__construct(); } - protected function interact(InputInterface $input, OutputInterface $output): void - { - $io = new SymfonyStyle($input, $output); - $oldName = $input->getArgument('old-name'); - $newName = $input->getArgument('new-name'); - - 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('old-name', $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('new-name', $requestedNewName); - } - } - public function __invoke( SymfonyStyle $io, - #[Argument(description: 'Current name of the API key to rename')] string $oldName, - #[Argument(description: 'New name to set to the API key')] string $newName, + #[Argument(description: 'Current name of the API key to rename'), Ask('What API key do you want to rename?')] + string $oldName, + #[Argument(description: 'New name to set to the API key'), Ask('What is the new name you want to set?')] + string $newName, ): int { $this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName)); $io->success('API key properly renamed'); diff --git a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php index d8c5f07f..2fe4fb9d 100644 --- a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php @@ -9,8 +9,6 @@ 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; @@ -32,11 +30,6 @@ class RenameApiKeyCommandTest extends TestCase $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), ); @@ -53,7 +46,6 @@ class RenameApiKeyCommandTest extends TestCase $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), ); @@ -70,7 +62,6 @@ class RenameApiKeyCommandTest extends TestCase $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), ); From 8fb8aea5f83a0a8b5897c7e4fd30a8d59591fe2b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 16:59:16 +0100 Subject: [PATCH 28/71] Replace interact method with Interact attribute in ReadEnvVarCommand --- module/CLI/src/Command/Config/ReadEnvVarCommand.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php index e3a38be6..69e1b89e 100644 --- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php +++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php @@ -8,10 +8,10 @@ use Closure; use Shlinkio\Shlink\Core\Config\EnvVars; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Interact; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function Shlinkio\Shlink\Config\formatEnvVarValue; @@ -37,9 +37,9 @@ class ReadEnvVarCommand extends Command parent::__construct(); } - protected function interact(InputInterface $input, OutputInterface $output): void + #[Interact] + public function askMissing(InputInterface $input, SymfonyStyle $io): void { - $io = new SymfonyStyle($input, $output); $envVar = $input->getArgument('env-var'); $validEnvVars = enumValues(EnvVars::class); From 88efe7d96282c028251081de3f196958438edf01 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 17:05:02 +0100 Subject: [PATCH 29/71] Fix PHPStan error --- module/CLI/src/Command/Config/ReadEnvVarCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php index 69e1b89e..527c0d12 100644 --- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php +++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php @@ -40,6 +40,7 @@ class ReadEnvVarCommand extends Command #[Interact] public function askMissing(InputInterface $input, SymfonyStyle $io): void { + /** @var string|null $envVar */ $envVar = $input->getArgument('env-var'); $validEnvVars = enumValues(EnvVars::class); From c496b7ac69819322cab1e9d4cc77df38efe890f9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 08:36:20 +0100 Subject: [PATCH 30/71] Require symfony/console 8.0 --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 8755c856..eeda898b 100644 --- a/composer.json +++ b/composer.json @@ -50,9 +50,9 @@ "shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0", "spiral/roadrunner": "^2025.1", "spiral/roadrunner-cli": "^2.7", - "spiral/roadrunner-http": "^3.5", - "spiral/roadrunner-jobs": "^4.6", - "symfony/console": "^8.0 || ^7.4", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-jobs": "^4.7", + "symfony/console": "^8.0", "symfony/filesystem": "^8.0", "symfony/lock": "^8.0", "symfony/process": "^8.0", From 7cdefcb4b6b7117c41c54db88cb50206c9fd2bb0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 08:39:05 +0100 Subject: [PATCH 31/71] Convert ManageRedirectRulesCommand into invokable command --- .../Command/Domain/DomainRedirectsCommand.php | 6 ++-- .../Integration/MatomoSendVisitsCommand.php | 15 +++----- .../ManageRedirectRulesCommand.php | 35 ++++++++----------- .../ManageRedirectRulesCommandTest.php | 6 ++-- 4 files changed, 24 insertions(+), 38 deletions(-) diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index fc94ee49..59deb35e 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -9,9 +9,9 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Interact; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function array_filter; @@ -32,7 +32,8 @@ class DomainRedirectsCommand extends Command parent::__construct(); } - protected function interact(InputInterface $input, OutputInterface $output): void + #[Interact] + public function askDomain(InputInterface $input, SymfonyStyle $io): void { /** @var string|null $domain */ $domain = $input->getArgument('domain'); @@ -40,7 +41,6 @@ class DomainRedirectsCommand extends Command return; } - $io = new SymfonyStyle($input, $output); $askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects'); /** @var string[] $availableDomains */ diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php index f5d8e84c..b45c0135 100644 --- a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Integration; -use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface; @@ -17,10 +16,12 @@ use Throwable; use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function sprintf; #[AsCommand( name: MatomoSendVisitsCommand::NAME, + description: 'Send existing visits to the configured matomo instance', help: <<setDescription(sprintf( - '%sSend existing visits to the configured matomo instance', - $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ', - )); - } - public function __invoke( SymfonyStyle $io, InputInterface $input, @@ -81,8 +74,8 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra // TODO Validate provided date formats $dateRange = buildDateRange( - startDate: $since !== null ? Chronos::parse($since) : null, - endDate: $until !== null ? Chronos::parse($until) : null, + startDate: normalizeOptionalDate($since), + endDate: normalizeOptionalDate($until), ); if ($input->isInteractive()) { diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php index 9a129e9d..81e41497 100644 --- a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php +++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php @@ -4,48 +4,41 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\RedirectRule; -use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; +#[AsCommand( + name: ManageRedirectRulesCommand::NAME, + description: 'Set redirect rules for a short URL', +)] class ManageRedirectRulesCommand extends Command { public const string NAME = 'short-url:manage-rules'; - private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; - public function __construct( protected readonly ShortUrlResolverInterface $shortUrlResolver, protected readonly ShortUrlRedirectRuleServiceInterface $ruleService, protected readonly RedirectRuleHandlerInterface $ruleHandler, ) { parent::__construct(); - $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( - $this, - shortCodeDesc: 'The short code which rules we want to set.', - domainDesc: 'The domain for the short code.', - ); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Set redirect rules for a short URL'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); + public function __invoke( + SymfonyStyle $io, + #[Argument('The short code which rules we want to set')] string $shortCode, + #[Option('The domain of the short code', shortcut: 'd')] string|null $domain = null, + ): int { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); try { $shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier); diff --git a/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php b/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php index 5cb45a4b..ac5073f4 100644 --- a/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php +++ b/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php @@ -48,7 +48,7 @@ class ManageRedirectRulesCommandTest extends TestCase $this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); $this->ruleHandler->expects($this->never())->method('manageRules'); - $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $exitCode = $this->commandTester->execute(['short-code' => 'foo']); $output = $this->commandTester->getDisplay(); self::assertEquals(Command::FAILURE, $exitCode); @@ -67,7 +67,7 @@ class ManageRedirectRulesCommandTest extends TestCase $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null); $this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); - $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $exitCode = $this->commandTester->execute(['short-code' => 'foo']); $output = $this->commandTester->getDisplay(); self::assertEquals(Command::SUCCESS, $exitCode); @@ -86,7 +86,7 @@ class ManageRedirectRulesCommandTest extends TestCase $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]); $this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []); - $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $exitCode = $this->commandTester->execute(['short-code' => 'foo']); $output = $this->commandTester->getDisplay(); self::assertEquals(Command::SUCCESS, $exitCode); From b7ae228a9523718719a2faf3ffa57716390edcd3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 08:57:54 +0100 Subject: [PATCH 32/71] Make tag and exclude-tag trully optional in ShortUrlsParamsInput --- .../ShortUrl/Input/ShortUrlsParamsInput.php | 27 +++++++++++-------- module/CLI/src/Input/ShortUrlDataInput.php | 2 +- .../ShortUrl/ListShortUrlsCommandTest.php | 25 +++++++++++------ 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php index 29c185d0..01a0e2bd 100644 --- a/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php +++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php @@ -45,16 +45,16 @@ final class ShortUrlsParamsInput )] public string|null $domain = null; - /** @var string[] */ + /** @var string[]|null */ #[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')] - public array $tags = []; + public array|null $tags = null; #[Option('If --tag is provided, returns only short URLs including ALL of them')] public bool $tagsAll = false; - /** @var string[] */ + /** @var string[]|null */ #[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')] - public array $excludeTags = []; + public array|null $excludeTags = null; #[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')] public bool $excludeTagsAll = false; @@ -88,17 +88,10 @@ final class ShortUrlsParamsInput public function toArray(OutputInterface $output): array { - $tagsMode = $this->tagsAll ? TagsMode::ALL->value : TagsMode::ANY->value; - $excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL->value : TagsMode::ANY->value; - $data = [ ShortUrlsParamsInputFilter::PAGE => $this->page, ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm, ShortUrlsParamsInputFilter::DOMAIN => $this->domain, - ShortUrlsParamsInputFilter::TAGS => array_unique($this->tags), - ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, - ShortUrlsParamsInputFilter::EXCLUDE_TAGS => array_unique($this->excludeTags), - ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode, ShortUrlsParamsInputFilter::ORDER_BY => $this->orderBy, ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output), ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output), @@ -107,6 +100,18 @@ final class ShortUrlsParamsInput ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName, ]; + if ($this->tags !== null) { + $tagsMode = $this->tagsAll ? TagsMode::ALL : TagsMode::ANY; + $data[ShortUrlsParamsInputFilter::TAGS_MODE] = $tagsMode->value; + $data[ShortUrlsParamsInputFilter::TAGS] = array_unique($this->tags); + } + + if ($this->excludeTags !== null) { + $excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL : TagsMode::ANY; + $data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE] = $excludeTagsMode->value; + $data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS] = array_unique($this->excludeTags); + } + if ($this->all) { $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; } diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php index 908e6536..d67bdd31 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputOption; final readonly class ShortUrlDataInput { - private readonly TagsOption $tagsOption; + private TagsOption $tagsOption; public function __construct(Command $command, private bool $longUrlAsOption = false) { diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 69190f7c..424cee51 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -203,25 +203,34 @@ class ListShortUrlsCommandTest extends TestCase array $commandArgs, int|null $page, string|null $searchTerm, - array $tags, + array|null $tags, string $tagsMode, string|null $startDate = null, string|null $endDate = null, - array $excludeTags = [], + array|null $excludeTags = null, string $excludeTagsMode = TagsMode::ANY->value, string|null $apiKeyName = null, ): void { - $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ + $expectedData = [ 'page' => $page, 'searchTerm' => $searchTerm, - 'tags' => $tags, 'tagsMode' => $tagsMode, 'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null, 'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null, - 'excludeTags' => $excludeTags, 'excludeTagsMode' => $excludeTagsMode, 'apiKeyName' => $apiKeyName, - ]))->willReturn(new Paginator(new ArrayAdapter([]))); + ]; + + if ($tags !== null) { + $expectedData['tags'] = $tags; + } + if ($excludeTags !== null) { + $expectedData['excludeTags'] = $excludeTags; + } + + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData( + $expectedData, + ))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->setInputs(['n']); $this->commandTester->execute($commandArgs); @@ -231,7 +240,7 @@ class ListShortUrlsCommandTest extends TestCase { yield [[], 1, null, [], TagsMode::ANY->value]; yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value]; - yield [['--tags-all' => true], 1, null, [], TagsMode::ALL->value]; + yield [['--tags-all' => true, '--tag' => ['foo']], 1, null, ['foo'], TagsMode::ALL->value]; yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value]; yield [ ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']], @@ -270,7 +279,7 @@ class ListShortUrlsCommandTest extends TestCase ['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true], 1, null, - [], + null, TagsMode::ANY->value, null, null, From 965d191ce1b7818047137dd0c1ae5dd8ea31051c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 09:27:52 +0100 Subject: [PATCH 33/71] Convert CreateShortUrlCommand into invokable command --- .../ShortUrl/CreateShortUrlCommand.php | 92 ++----------------- .../ShortUrl/Input/ShortUrlCreationInput.php | 57 ++++++++++++ .../ShortUrl/Input/ShortUrlDataInput.php | 80 ++++++++++++++++ .../ShortUrl/CreateShortUrlCommandTest.php | 12 +-- 4 files changed, 153 insertions(+), 88 deletions(-) create mode 100644 module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php create mode 100644 module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 2e52571b..a5133d74 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -4,109 +4,42 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput; +use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlCreationInput; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; use Symfony\Component\Console\Command\Command; -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 sprintf; +#[AsCommand( + name: CreateShortUrlCommand::NAME, + description: 'Generates a short URL for provided long URL and returns it', +)] class CreateShortUrlCommand extends Command { public const string NAME = 'short-url:create'; - private SymfonyStyle $io; - private readonly ShortUrlDataInput $shortUrlDataInput; - public function __construct( private readonly UrlShortenerInterface $urlShortener, private readonly ShortUrlStringifierInterface $stringifier, private readonly UrlShortenerOptions $options, ) { parent::__construct(); - $this->shortUrlDataInput = new ShortUrlDataInput($this); } - protected function configure(): void + public function __invoke(SymfonyStyle $io, #[MapInput] ShortUrlCreationInput $inputData): int { - $this - ->setName(self::NAME) - ->setDescription('Generates a short URL for provided long URL and returns it') - ->addOption( - 'domain', - 'd', - InputOption::VALUE_REQUIRED, - 'The domain to which this short URL will be attached.', - ) - ->addOption( - 'custom-slug', - 'c', - InputOption::VALUE_REQUIRED, - 'If provided, this slug will be used instead of generating a short code', - ) - ->addOption( - 'short-code-length', - 'l', - InputOption::VALUE_REQUIRED, - 'The length for generated short code (it will be ignored if --custom-slug was provided).', - ) - ->addOption( - 'path-prefix', - 'p', - InputOption::VALUE_REQUIRED, - 'Prefix to prepend before the generated short code or provided custom slug', - ) - ->addOption( - 'find-if-exists', - 'f', - InputOption::VALUE_NONE, - 'This will force existing matching URL to be returned if found, instead of creating a new one.', - ); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $this->verifyLongUrlArgument($input, $output); - } - - private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void - { - $longUrl = $input->getArgument('longUrl'); - if (! empty($longUrl)) { - return; - } - - $io = $this->getIO($input, $output); - $longUrl = $io->ask('Which URL do you want to shorten?'); - if (! empty($longUrl)) { - $input->setArgument('longUrl', $longUrl); - } - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = $this->getIO($input, $output); - try { - $result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation( - $input, - $this->options, - customSlugField: 'custom-slug', - shortCodeLengthField: 'short-code-length', - pathPrefixField: 'path-prefix', - findIfExistsField: 'find-if-exists', - domainField: 'domain', - )); + $result = $this->urlShortener->shorten($inputData->toShortUrlCreation($this->options)); $result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning( 'Short URL properly created, but the real-time updates cannot be notified when generating the ' - . 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.', + . 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.', )); $io->writeln([ @@ -119,9 +52,4 @@ class CreateShortUrlCommand extends Command return self::FAILURE; } } - - private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle - { - return $this->io ??= new SymfonyStyle($input, $output); - } } diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php new file mode 100644 index 00000000..729c8a94 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php @@ -0,0 +1,57 @@ +shortCodeLength ?? $options->defaultShortCodesLength; + return ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => $this->longUrl, + ShortUrlInputFilter::DOMAIN => $this->domain, + ShortUrlInputFilter::CUSTOM_SLUG => $this->customSlug, + ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, + ShortUrlInputFilter::PATH_PREFIX => $this->pathPrefix, + ShortUrlInputFilter::FIND_IF_EXISTS => $this->findIfExists, + ...$this->commonData->toArray(), + ], $options); + } +} diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php new file mode 100644 index 00000000..988e87a0 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php @@ -0,0 +1,80 @@ +validSince !== null) { + $data[ShortUrlInputFilter::VALID_SINCE] = $this->validSince; + } + if ($this->validUntil !== null) { + $data[ShortUrlInputFilter::VALID_UNTIL] = $this->validUntil; + } + if ($this->maxVisits !== null) { + $data[ShortUrlInputFilter::MAX_VISITS] = $this->maxVisits; + } + if ($this->tags !== null) { + $data[ShortUrlInputFilter::TAGS] = array_unique($this->tags); + } + if ($this->title !== null) { + $data[ShortUrlInputFilter::TITLE] = $this->title; + } + if ($this->crawlable !== null) { + $data[ShortUrlInputFilter::CRAWLABLE] = $this->crawlable; + } + if ($this->noForwardQuery !== null) { + $data[ShortUrlInputFilter::FORWARD_QUERY] = !$this->noForwardQuery; + } + + return $data; + } +} diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 9452aad5..07dd1efb 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -54,7 +54,7 @@ class CreateShortUrlCommandTest extends TestCase ); $this->commandTester->execute([ - 'longUrl' => 'http://domain.com/foo/bar', + 'long-url' => 'http://domain.com/foo/bar', '--max-visits' => '3', ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); @@ -87,7 +87,7 @@ class CreateShortUrlCommandTest extends TestCase ); $this->stringifier->method('stringify')->willReturn(''); - $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']); + $this->commandTester->execute(['long-url' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']); $output = $this->commandTester->getDisplay(); self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode()); @@ -109,7 +109,7 @@ class CreateShortUrlCommandTest extends TestCase ); $this->commandTester->execute([ - 'longUrl' => 'http://domain.com/foo/bar', + 'long-url' => 'http://domain.com/foo/bar', '--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'], ]); $output = $this->commandTester->getDisplay(); @@ -129,7 +129,7 @@ class CreateShortUrlCommandTest extends TestCase )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake())); $this->stringifier->method('stringify')->willReturn(''); - $input['longUrl'] = 'http://domain.com/foo/bar'; + $input['long-url'] = 'http://domain.com/foo/bar'; $this->commandTester->execute($input); self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); @@ -156,7 +156,7 @@ class CreateShortUrlCommandTest extends TestCase )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl)); $this->stringifier->method('stringify')->willReturn(''); - $options['longUrl'] = 'http://domain.com/foo/bar'; + $options['long-url'] = 'http://domain.com/foo/bar'; $this->commandTester->execute($options); } @@ -178,7 +178,7 @@ class CreateShortUrlCommandTest extends TestCase ); $this->stringifier->method('stringify')->willReturn('stringified_short_url'); - $this->commandTester->execute(['longUrl' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]); + $this->commandTester->execute(['long-url' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]); $output = $this->commandTester->getDisplay(); $assert($output); From 635e968bb2bbbe72a2fb474fa6fddfde4d69d067 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 09:35:38 +0100 Subject: [PATCH 34/71] Convert EditShortUrlCommand into invokable command --- .../Command/ShortUrl/EditShortUrlCommand.php | 46 +++---- module/CLI/src/Input/ShortUrlDataInput.php | 128 ------------------ module/CLI/src/Input/ShortUrlDataOption.php | 39 ------ module/CLI/src/Input/TagsOption.php | 41 ------ .../ShortUrl/EditShortUrlCommandTest.php | 4 +- 5 files changed, 22 insertions(+), 236 deletions(-) delete mode 100644 module/CLI/src/Input/ShortUrlDataInput.php delete mode 100644 module/CLI/src/Input/ShortUrlDataOption.php delete mode 100644 module/CLI/src/Input/TagsOption.php diff --git a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php index 16fe9458..7bdd82e2 100644 --- a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php @@ -4,55 +4,49 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput; -use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; +use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlDataInput; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; +#[AsCommand( + name: EditShortUrlCommand::NAME, + description: 'Edit an existing short URL', +)] class EditShortUrlCommand extends Command { public const string NAME = 'short-url:edit'; - private readonly ShortUrlDataInput $shortUrlDataInput; - private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; - public function __construct( private readonly ShortUrlServiceInterface $shortUrlService, private readonly ShortUrlStringifierInterface $stringifier, ) { parent::__construct(); - - $this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true); - $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( - $this, - shortCodeDesc: 'The short code to edit', - domainDesc: 'The domain to which the short URL is attached.', - ); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Edit an existing short URL'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); + public function __invoke( + SymfonyStyle $io, + #[Argument('The short code to edit')] string $shortCode, + #[MapInput] ShortUrlDataInput $data, + #[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null, + #[Option('The long URL to set', shortcut: 'l')] string|null $longUrl = null, + ): int { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); try { $shortUrl = $this->shortUrlService->updateShortUrl( $identifier, - $this->shortUrlDataInput->toShortUrlEdition($input), + ShortUrlEdition::fromRawData($data->toArray()), ); $io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl))); diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php deleted file mode 100644 index d67bdd31..00000000 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ /dev/null @@ -1,128 +0,0 @@ -addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set'); - } else { - $command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set'); - } - - $this->tagsOption = new TagsOption($command, 'Tags to apply to the short URL'); - - $command - ->addOption( - ShortUrlDataOption::VALID_SINCE->value, - ShortUrlDataOption::VALID_SINCE->shortcut(), - InputOption::VALUE_REQUIRED, - 'The date from which this short URL will be valid. ' - . 'If someone tries to access it before this date, it will not be found.', - ) - ->addOption( - ShortUrlDataOption::VALID_UNTIL->value, - ShortUrlDataOption::VALID_UNTIL->shortcut(), - InputOption::VALUE_REQUIRED, - 'The date until which this short URL will be valid. ' - . 'If someone tries to access it after this date, it will not be found.', - ) - ->addOption( - ShortUrlDataOption::MAX_VISITS->value, - ShortUrlDataOption::MAX_VISITS->shortcut(), - InputOption::VALUE_REQUIRED, - 'This will limit the number of visits for this short URL.', - ) - ->addOption( - ShortUrlDataOption::TITLE->value, - ShortUrlDataOption::TITLE->shortcut(), - InputOption::VALUE_REQUIRED, - 'A descriptive title for the short URL.', - ) - ->addOption( - ShortUrlDataOption::CRAWLABLE->value, - ShortUrlDataOption::CRAWLABLE->shortcut(), - InputOption::VALUE_NONE, - 'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.', - ) - ->addOption( - ShortUrlDataOption::NO_FORWARD_QUERY->value, - ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(), - InputOption::VALUE_NONE, - 'Disables the forwarding of the query string to the long URL, when the short URL is visited.', - ); - } - - public function toShortUrlEdition(InputInterface $input): ShortUrlEdition - { - return ShortUrlEdition::fromRawData($this->getCommonData($input)); - } - - public function toShortUrlCreation( - InputInterface $input, - UrlShortenerOptions $options, - string $customSlugField, - string $shortCodeLengthField, - string $pathPrefixField, - string $findIfExistsField, - string $domainField, - ): ShortUrlCreation { - $shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength; - return ShortUrlCreation::fromRawData([ - ...$this->getCommonData($input), - ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField), - ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, - ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField), - ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField), - ShortUrlInputFilter::DOMAIN => $input->getOption($domainField), - ], $options); - } - - private function getCommonData(InputInterface $input): array - { - $longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl'); - $data = [ShortUrlInputFilter::LONG_URL => $longUrl]; - - // Avoid setting arguments that were not explicitly provided. - // This is important when editing short URLs and should not make a difference when creating. - if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) { - $data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since'); - } - if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) { - $data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until'); - } - if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) { - $maxVisits = $input->getOption('max-visits'); - $data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null; - } - if ($this->tagsOption->exists($input)) { - $data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input); - } - if (ShortUrlDataOption::TITLE->wasProvided($input)) { - $data[ShortUrlInputFilter::TITLE] = $input->getOption('title'); - } - if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) { - $data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable'); - } - if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) { - $data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query'); - } - - return $data; - } -} diff --git a/module/CLI/src/Input/ShortUrlDataOption.php b/module/CLI/src/Input/ShortUrlDataOption.php deleted file mode 100644 index 4d8b582e..00000000 --- a/module/CLI/src/Input/ShortUrlDataOption.php +++ /dev/null @@ -1,39 +0,0 @@ - 's', - self::VALID_UNTIL => 'u', - self::MAX_VISITS => 'm', - self::TITLE => null, - self::CRAWLABLE => 'r', - self::NO_FORWARD_QUERY => 'w', - }; - } - - public function wasProvided(InputInterface $input): bool - { - $option = sprintf('--%s', $this->value); - $shortcut = $this->shortcut(); - - return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]); - } -} diff --git a/module/CLI/src/Input/TagsOption.php b/module/CLI/src/Input/TagsOption.php deleted file mode 100644 index 1cdee7e8..00000000 --- a/module/CLI/src/Input/TagsOption.php +++ /dev/null @@ -1,41 +0,0 @@ -addOption( - 'tag', - 't', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - $description, - ); - } - - /** - * Whether tags have been set or not, via `--tag` or `-t` - */ - public function exists(InputInterface $input): bool - { - return $input->hasParameterOption(['--tag', '-t']); - } - - /** - * @return string[] - */ - public function get(InputInterface $input): array - { - return array_unique($input->getOption('tag')); - } -} diff --git a/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php index 0fd9a860..ba021fe7 100644 --- a/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php @@ -40,7 +40,7 @@ class EditShortUrlCommandTest extends TestCase ); $this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo'); - $this->commandTester->execute(['shortCode' => 'foobar']); + $this->commandTester->execute(['short-code' => 'foobar']); $output = $this->commandTester->getDisplay(); $exitCode = $this->commandTester->getStatusCode(); @@ -59,7 +59,7 @@ class EditShortUrlCommandTest extends TestCase $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e); $this->stringifier->expects($this->never())->method('stringify'); - $this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]); + $this->commandTester->execute(['short-code' => 'foo'], ['verbosity' => $verbosity]); $output = $this->commandTester->getDisplay(); $exitCode = $this->commandTester->getStatusCode(); From 36cb760a88434216cb2140d6a5d7defe9fe78a50 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 09:47:16 +0100 Subject: [PATCH 35/71] Convert DeleteShortUrlCommand into invokable command --- .../ShortUrl/DeleteShortUrlCommand.php | 47 +++++++------------ .../Command/ShortUrl/EditShortUrlCommand.php | 2 +- .../ShortUrl/DeleteShortUrlCommandTest.php | 8 ++-- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index e6a11ea1..9d578729 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -4,53 +4,40 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; -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 sprintf; +#[AsCommand(name: DeleteShortUrlCommand::NAME, description: 'Deletes a short URL')] class DeleteShortUrlCommand extends Command { public const string NAME = 'short-url:delete'; - private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; - public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService) { parent::__construct(); - $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( - $this, - shortCodeDesc: 'The short code for the short URL to be deleted', - domainDesc: 'The domain if the short code does not belong to the default one', - ); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Deletes a short URL') - ->addOption( - 'ignore-threshold', - 'i', - InputOption::VALUE_NONE, - 'Ignores the safety visits threshold check, which could make short URLs with many visits to be ' - . 'accidentally deleted', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); - $ignoreThreshold = $input->getOption('ignore-threshold'); + public function __invoke( + SymfonyStyle $io, + #[Argument('The short code for the short URL to be deleted')] string $shortCode, + #[Option('TThe domain if the short code does not belong to the default one', shortcut: 'd')] + string|null $domain = null, + #[Option( + 'Ignores the safety visits threshold check, which could make short URLs with many visits to be ' + . 'accidentally deleted', + shortcut: 'i', + )] + bool $ignoreThreshold = false, + ): int { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); try { $this->runDelete($io, $identifier, $ignoreThreshold); diff --git a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php index 7bdd82e2..f4e3b06e 100644 --- a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php @@ -36,8 +36,8 @@ class EditShortUrlCommand extends Command public function __invoke( SymfonyStyle $io, - #[Argument('The short code to edit')] string $shortCode, #[MapInput] ShortUrlDataInput $data, + #[Argument('The short code to edit')] string $shortCode, #[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null, #[Option('The long URL to set', shortcut: 'l')] string|null $longUrl = null, ): int { diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 62872123..c8d94388 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -39,7 +39,7 @@ class DeleteShortUrlCommandTest extends TestCase $this->isFalse(), ); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString( @@ -58,7 +58,7 @@ class DeleteShortUrlCommandTest extends TestCase $this->isFalse(), )->willThrowException(Exception\ShortUrlNotFoundException::fromNotFound($identifier)); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); @@ -88,7 +88,7 @@ class DeleteShortUrlCommandTest extends TestCase }); $this->commandTester->setInputs($retryAnswer); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf( @@ -118,7 +118,7 @@ class DeleteShortUrlCommandTest extends TestCase )); $this->commandTester->setInputs(['no']); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf( From d481c06f09096f494961fa63b7c34b60bfee8752 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 10:04:43 +0100 Subject: [PATCH 36/71] Convert DeleteShortUrlVisitsCommand into invokable command --- .../ShortUrl/DeleteShortUrlCommand.php | 2 +- .../ShortUrl/DeleteShortUrlVisitsCommand.php | 46 +++++++++---------- module/CLI/src/Command/Util/CommandUtils.php | 28 +++++++++++ .../DeleteShortUrlVisitsCommandTest.php | 8 ++-- 4 files changed, 55 insertions(+), 29 deletions(-) create mode 100644 module/CLI/src/Command/Util/CommandUtils.php diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index 9d578729..c3b4ba0c 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -28,7 +28,7 @@ class DeleteShortUrlCommand extends Command public function __invoke( SymfonyStyle $io, #[Argument('The short code for the short URL to be deleted')] string $shortCode, - #[Option('TThe domain if the short code does not belong to the default one', shortcut: 'd')] + #[Option('The domain if the short code does not belong to the default one', shortcut: 'd')] string|null $domain = null, #[Option( 'Ignores the safety visits threshold check, which could make short URLs with many visits to be ' diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php index 4c238f31..16667eb3 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php @@ -4,41 +4,44 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand; -use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface; -use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; -class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand +#[AsCommand(DeleteShortUrlVisitsCommand::NAME, 'Deletes visits from a short URL')] +class DeleteShortUrlVisitsCommand extends Command { public const string NAME = 'short-url:visits-delete'; - private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; - public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter) { parent::__construct(); - $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( - $this, - shortCodeDesc: 'The short code for the short URL which visits will be deleted', - domainDesc: 'The domain if the short code does not belong to the default one', + } + + public function __invoke( + SymfonyStyle $io, + #[Argument('The short code for the short URL which visits will be deleted')] string $shortCode, + #[Option('The domain if the short code does not belong to the default one', shortcut: 'd')] + string|null $domain = null, + ): int { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); + return CommandUtils::executeWithWarning( + 'You are about to delete all visits for a short URL. This operation cannot be undone', + $io, + fn () => $this->deleteVisits($io, $identifier), ); } - protected function configure(): void + private function deleteVisits(SymfonyStyle $io, ShortUrlIdentifier $identifier): int { - $this - ->setName(self::NAME) - ->setDescription('Deletes visits from a short URL'); - } - - protected function doExecute(InputInterface $input, SymfonyStyle $io): int - { - $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); try { $result = $this->deleter->deleteShortUrlVisits($identifier); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); @@ -49,9 +52,4 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand return self::INVALID; } } - - protected function getWarningMessage(): string - { - return 'You are about to delete all visits for a short URL. This operation cannot be undone.'; - } } diff --git a/module/CLI/src/Command/Util/CommandUtils.php b/module/CLI/src/Command/Util/CommandUtils.php new file mode 100644 index 00000000..76085f1a --- /dev/null +++ b/module/CLI/src/Command/Util/CommandUtils.php @@ -0,0 +1,28 @@ +warning($warning); + if (! $io->confirm('Do you want to proceed?', default: false)) { + $io->info('Operation aborted'); + return Command::SUCCESS; + } + + return $callback(); + } +} diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php index 3efa94b5..6f066d0f 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php @@ -36,7 +36,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase $this->deleter->expects($this->never())->method('deleteShortUrlVisits'); $this->commandTester->setInputs($input); - $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $exitCode = $this->commandTester->execute(['short-code' => 'foo']); $output = $this->commandTester->getDisplay(); self::assertEquals(Command::SUCCESS, $exitCode); @@ -67,8 +67,8 @@ class DeleteShortUrlVisitsCommandTest extends TestCase public static function provideErrorArgs(): iterable { - yield 'domain' => [['shortCode' => 'foo'], 'Short URL not found for "foo"']; - yield 'no domain' => [['shortCode' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"']; + yield 'domain' => [['short-code' => 'foo'], 'Short URL not found for "foo"']; + yield 'no domain' => [['short-code' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"']; } #[Test] @@ -77,7 +77,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase $this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willReturn(new BulkDeleteResult(5)); $this->commandTester->setInputs(['yes']); - $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $exitCode = $this->commandTester->execute(['short-code' => 'foo']); $output = $this->commandTester->getDisplay(); self::assertEquals(Command::SUCCESS, $exitCode); From c30ec261c93ca8be9127eb12934f8c159fae3a28 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 10:08:09 +0100 Subject: [PATCH 37/71] Convert DeleteOrphanVisitsCommand into invokable command --- .../Visit/AbstractDeleteVisitsCommand.php | 34 ------------------- .../Visit/DeleteOrphanVisitsCommand.php | 24 ++++++------- 2 files changed, 12 insertions(+), 46 deletions(-) delete mode 100644 module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php diff --git a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php b/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php deleted file mode 100644 index d8ef98e3..00000000 --- a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php +++ /dev/null @@ -1,34 +0,0 @@ -confirm($io)) { - $io->info('Operation aborted'); - return self::SUCCESS; - } - - return $this->doExecute($input, $io); - } - - private function confirm(SymfonyStyle $io): bool - { - $io->warning($this->getWarningMessage()); - return $io->confirm('Continue deleting visits?', false); - } - - abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int; - - abstract protected function getWarningMessage(): string; -} diff --git a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php index 77fefaaa..654b92bf 100644 --- a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface; -use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; -class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand +#[AsCommand(DeleteOrphanVisitsCommand::NAME, 'Deletes all orphan visits')] +class DeleteOrphanVisitsCommand extends Command { public const string NAME = 'visit:orphan-delete'; @@ -19,23 +22,20 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand parent::__construct(); } - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this - ->setName(self::NAME) - ->setDescription('Deletes all orphan visits'); + return CommandUtils::executeWithWarning( + 'You are about to delete all orphan visits. This operation cannot be undone', + $io, + fn () => $this->deleteVisits($io), + ); } - protected function doExecute(InputInterface $input, SymfonyStyle $io): int + private function deleteVisits(SymfonyStyle $io): int { $result = $this->deleter->deleteOrphanVisits(); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); return self::SUCCESS; } - - protected function getWarningMessage(): string - { - return 'You are about to delete all orphan visits. This operation cannot be undone.'; - } } From 9e4ea801397f654f177ad1b85eb9e370981cdf40 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 10:16:09 +0100 Subject: [PATCH 38/71] Convert ResolveUrlCommand into invokable command --- .../Command/ShortUrl/ResolveUrlCommand.php | 56 +++++++------------ .../ShortUrl/ResolveUrlCommandTest.php | 4 +- 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index b6bf71f7..b70ba70e 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -4,60 +4,42 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; +#[AsCommand(ResolveUrlCommand::NAME, 'Returns the long URL behind a short code')] class ResolveUrlCommand extends Command { - public const string NAME = 'short-url:parse'; - - private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public const string NAME = 'short-url:resolve'; public function __construct(private readonly ShortUrlResolverInterface $urlResolver) { parent::__construct(); - $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( - $this, - shortCodeDesc: 'The short code to parse', - domainDesc: 'The domain to which the short URL is attached.', - ); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Returns the long URL behind a short code'); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $shortCode = $this->shortUrlIdentifierInput->shortCode($input); - if (! empty($shortCode)) { - return; - } - - $io = new SymfonyStyle($input, $output); - $shortCode = $io->ask('A short code was not provided. Which short code do you want to parse?'); - if (! empty($shortCode)) { - $input->setArgument('shortCode', $shortCode); - } - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); + public function __invoke( + SymfonyStyle $io, + #[ + Argument('The short code to resolve'), + Ask('A short code was not provided. Which short code do you want to resolve?'), + ] + string $shortCode, + #[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null, + ): int { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); try { - $url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input)); - $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); + $url = $this->urlResolver->resolveShortUrl($identifier); + $io->writeln(sprintf('Long URL: %s', $url->getLongUrl())); return self::SUCCESS; } catch (ShortUrlNotFoundException $e) { $io->error($e->getMessage()); diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 742ae05c..30060dd6 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -40,7 +40,7 @@ class ResolveUrlCommandTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), )->willReturn($shortUrl); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output); } @@ -68,7 +68,7 @@ class ResolveUrlCommandTest extends TestCase ShortUrlNotFoundException::fromNotFound($identifier), ); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); } From da53c5a2069406c6c68b501771d305d94735c305 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 14:17:36 +0100 Subject: [PATCH 39/71] Fix notices reported by latest PHPUnit version --- module/CLI/test/ApiKey/RoleResolverTest.php | 14 ++-------- .../Command/Api/GenerateKeyCommandTest.php | 2 +- .../Command/Db/CreateDatabaseCommandTest.php | 28 ++++++++++--------- .../Command/Db/MigrateDatabaseCommandTest.php | 6 ++-- .../MatomoSendVisitsCommandTest.php | 9 ++++-- .../ShortUrl/CreateShortUrlCommandTest.php | 17 ++++------- .../Command/Tag/DeleteTagsCommandTest.php | 2 ++ .../Command/Visit/LocateVisitsCommandTest.php | 13 +++++---- .../test/Factory/ApplicationFactoryTest.php | 4 +-- module/CLI/test/Input/InputUtilsTest.php | 2 ++ .../RedirectRule/RedirectRuleHandlerTest.php | 2 ++ module/CLI/test/Util/CliTestUtils.php | 6 ++-- module/CLI/test/Util/ProcessRunnerTest.php | 2 +- module/CLI/test/Util/ShlinkTableTest.php | 27 ++++++------------ module/Core/test/Action/PixelActionTest.php | 2 +- .../Core/test/Action/RedirectActionTest.php | 7 +++-- .../Config/NotFoundRedirectResolverTest.php | 2 ++ module/Core/test/Domain/DomainServiceTest.php | 22 +++++++-------- .../NotFoundRedirectHandlerTest.php | 2 +- .../NotFoundTrackerMiddlewareTest.php | 2 +- ...DbConnectionEventListenerDelegatorTest.php | 2 +- .../Helper/RequestIdProviderTest.php | 2 +- .../LocateUnlocatedVisitsTest.php | 2 ++ .../Matomo/SendVisitToMatomoTest.php | 2 +- .../NotifyNewShortUrlToMercureTest.php | 3 +- .../NotifyNewShortUrlToRabbitMqTest.php | 2 ++ .../RabbitMq/NotifyVisitToRabbitMqTest.php | 4 +++ .../NotifyNewShortUrlToRedisTest.php | 1 + .../RedisPubSub/NotifyVisitToRedisTest.php | 1 + .../EventDispatcher/UpdateGeoLiteDbTest.php | 1 + .../Geolocation/GeolocationDbUpdaterTest.php | 2 ++ .../Importer/ImportedLinksProcessorTest.php | 2 ++ .../test/Matomo/MatomoVisitSenderTest.php | 9 +++--- .../ShortUrl/DeleteShortUrlServiceTest.php | 14 ++++++---- .../ShortUrlTitleResolutionHelperTest.php | 7 +++-- .../ExtraPathRedirectMiddlewareTest.php | 10 +++---- ...ersistenceShortUrlRelationResolverTest.php | 4 +-- .../test/ShortUrl/ShortUrlServiceTest.php | 6 +--- .../Core/test/ShortUrl/UrlShortenerTest.php | 11 ++++---- module/Core/test/Tag/TagServiceTest.php | 7 +---- module/Core/test/Visit/RequestTrackerTest.php | 12 ++++++++ .../Core/test/Visit/VisitsStatsHelperTest.php | 8 +++--- module/Rest/test/Action/HealthActionTest.php | 6 ++-- .../test/Action/MercureInfoActionTest.php | 3 +- .../SingleStepCreateShortUrlActionTest.php | 2 +- .../test/Action/Tag/UpdateTagActionTest.php | 1 + .../Action/Visit/OrphanVisitsActionTest.php | 2 ++ .../Middleware/CrossDomainMiddlewareTest.php | 18 ++++++------ ...ortUrlContentNegotiationMiddlewareTest.php | 4 +-- .../Rest/test/Service/ApiKeyServiceTest.php | 2 ++ phpunit.xml.dist | 2 ++ 51 files changed, 174 insertions(+), 149 deletions(-) diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index 28d194c3..b6bf1403 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -15,7 +15,6 @@ use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Symfony\Component\Console\Input\InputInterface; class RoleResolverTest extends TestCase { @@ -46,17 +45,6 @@ class RoleResolverTest extends TestCase public static function provideRoles(): iterable { $domain = self::domainWithId(Domain::withAuthority('example.com')); - $buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface { - $returnMap = []; - foreach ($definition as $param => $returnValue) { - $returnMap[] = [$param, $returnValue]; - } - - $input = $test->createStub(InputInterface::class); - $input->method('getOption')->willReturnMap($returnMap); - - return $input; - }; yield 'no roles' => [ new ApiKeyInput(), @@ -107,6 +95,8 @@ class RoleResolverTest extends TestCase $input = new ApiKeyInput(); $input->domain = 'default.com'; + $this->domainService->expects($this->never())->method('getOrCreate'); + $this->expectException(InvalidRoleConfigException::class); [...$this->resolver->determineRoles($input)]; diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 849ea0cf..8b62a549 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -25,7 +25,7 @@ class GenerateKeyCommandTest extends TestCase protected function setUp(): void { $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); - $roleResolver = $this->createMock(RoleResolverInterface::class); + $roleResolver = $this->createStub(RoleResolverInterface::class); $roleResolver->method('determineRoles')->willReturn([]); $command = new GenerateKeyCommand($this->apiKeyService, $roleResolver); diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 8fd34fd2..6e680c7a 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -16,6 +16,7 @@ use Exception; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; @@ -31,19 +32,19 @@ class CreateDatabaseCommandTest extends TestCase private CommandTester $commandTester; private MockObject & ProcessRunnerInterface $processHelper; private MockObject & Connection $regularConn; - private MockObject & ClassMetadataFactory $metadataFactory; + private Stub & ClassMetadataFactory $metadataFactory; /** @var MockObject&AbstractSchemaManager */ private MockObject & AbstractSchemaManager $schemaManager; - private MockObject & Driver $driver; + private Stub & Driver $driver; protected function setUp(): void { - $locker = $this->createMock(LockFactory::class); - $lock = $this->createMock(SharedLockInterface::class); + $locker = $this->createStub(LockFactory::class); + $lock = $this->createStub(SharedLockInterface::class); $lock->method('acquire')->willReturn(true); $locker->method('createLock')->willReturn($lock); - $phpExecutableFinder = $this->createMock(PhpExecutableFinder::class); + $phpExecutableFinder = $this->createStub(PhpExecutableFinder::class); $phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php'); $this->processHelper = $this->createMock(ProcessRunnerInterface::class); @@ -51,15 +52,15 @@ class CreateDatabaseCommandTest extends TestCase $this->regularConn = $this->createMock(Connection::class); $this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager); - $this->driver = $this->createMock(Driver::class); + $this->driver = $this->createStub(Driver::class); $this->regularConn->method('getDriver')->willReturn($this->driver); - $this->metadataFactory = $this->createMock(ClassMetadataFactory::class); - $em = $this->createMock(EntityManagerInterface::class); + $this->metadataFactory = $this->createStub(ClassMetadataFactory::class); + $em = $this->createStub(EntityManagerInterface::class); $em->method('getConnection')->willReturn($this->regularConn); $em->method('getMetadataFactory')->willReturn($this->metadataFactory); - $noDbNameConn = $this->createMock(Connection::class); + $noDbNameConn = $this->createStub(Connection::class); $noDbNameConn->method('createSchemaManager')->willReturn($this->schemaManager); $command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn); @@ -70,13 +71,13 @@ class CreateDatabaseCommandTest extends TestCase public function successMessageIsPrintedIfDatabaseAlreadyExists(): void { $this->regularConn->expects($this->never())->method('getParams'); - $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $metadataMock = $this->createMock(ClassMetadata::class); $metadataMock->expects($this->once())->method('getTableName')->willReturn('foo_table'); $this->metadataFactory->method('getAllMetadata')->willReturn([$metadataMock]); $this->schemaManager->expects($this->never())->method('createDatabase'); $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']); + $this->processHelper->expects($this->never())->method('run'); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -87,13 +88,14 @@ class CreateDatabaseCommandTest extends TestCase #[Test] public function databaseIsCreatedIfItDoesNotExist(): void { - $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $this->driver->method('getDatabasePlatform')->willReturn($this->createStub(AbstractPlatform::class)); $shlinkDatabase = 'shlink_database'; $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); $this->metadataFactory->method('getAllMetadata')->willReturn([]); $this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase); $this->schemaManager->expects($this->once())->method('listTableNames')->willThrowException(new Exception('')); + $this->processHelper->expects($this->once())->method('run'); $this->commandTester->execute([]); } @@ -102,9 +104,9 @@ class CreateDatabaseCommandTest extends TestCase public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void { $this->regularConn->expects($this->never())->method('getParams'); - $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $this->driver->method('getDatabasePlatform')->willReturn($this->createStub(AbstractPlatform::class)); - $metadata = $this->createMock(ClassMetadata::class); + $metadata = $this->createStub(ClassMetadata::class); $metadata->method('getTableName')->willReturn('shlink_table'); $this->metadataFactory->method('getAllMetadata')->willReturn([$metadata]); $this->schemaManager->expects($this->never())->method('createDatabase'); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index a9fc07ad..f3312803 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -23,12 +23,12 @@ class MigrateDatabaseCommandTest extends TestCase protected function setUp(): void { - $locker = $this->createMock(LockFactory::class); - $lock = $this->createMock(SharedLockInterface::class); + $locker = $this->createStub(LockFactory::class); + $lock = $this->createStub(SharedLockInterface::class); $lock->method('acquire')->willReturn(true); $locker->method('createLock')->willReturn($lock); - $phpExecutableFinder = $this->createMock(PhpExecutableFinder::class); + $phpExecutableFinder = $this->createStub(PhpExecutableFinder::class); $phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php'); $this->processHelper = $this->createMock(ProcessRunnerInterface::class); diff --git a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php index dadce789..a1fd4614 100644 --- a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php +++ b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php @@ -3,6 +3,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Integration; use Exception; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; @@ -27,6 +28,8 @@ class MatomoSendVisitsCommandTest extends TestCase #[Test] public function warningDisplayedIfIntegrationIsNotEnabled(): void { + $this->visitSender->expects($this->never())->method('sendVisitsInDateRange'); + [$output, $exitCode] = $this->executeCommand(matomoEnabled: false); self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output); @@ -38,7 +41,7 @@ class MatomoSendVisitsCommandTest extends TestCase #[TestWith([false], 'not interactive')] public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void { - $this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult()); + $this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult()); [$output] = $this->executeCommand(['y'], ['interactive' => $interactive]); @@ -80,7 +83,7 @@ class MatomoSendVisitsCommandTest extends TestCase #[Test] public function printsResultOfSendingVisits(): void { - $this->visitSender->method('sendVisitsInDateRange')->willReturnCallback( + $this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturnCallback( function (DateRange $_, MatomoSendVisitsCommand $command): SendVisitsResult { // Call it a few times for an easier match of its result in the command putput $command->success(0); @@ -99,7 +102,7 @@ class MatomoSendVisitsCommandTest extends TestCase self::assertStringContainsString('...E.E', $output); } - #[Test] + #[Test, AllowMockObjectsWithoutExpectations] #[TestWith([[], 'All time'])] #[TestWith([['--since' => '2023-05-01'], 'Since 2023-05-01 00:00:00'])] #[TestWith([['--until' => '2023-05-01'], 'Until 2023-05-01 00:00:00'])] diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 07dd1efb..805dc253 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; @@ -27,12 +28,12 @@ class CreateShortUrlCommandTest extends TestCase { private CommandTester $commandTester; private MockObject & UrlShortenerInterface $urlShortener; - private MockObject & ShortUrlStringifierInterface $stringifier; + private Stub & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { $this->urlShortener = $this->createMock(UrlShortenerInterface::class); - $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); + $this->stringifier = $this->createStub(ShortUrlStringifierInterface::class); $command = new CreateShortUrlCommand( $this->urlShortener, @@ -49,9 +50,7 @@ class CreateShortUrlCommandTest extends TestCase $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn( UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl), ); - $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( - 'stringified_short_url', - ); + $this->stringifier->method('stringify')->with($shortUrl)->willReturn('stringified_short_url'); $this->commandTester->execute([ 'long-url' => 'http://domain.com/foo/bar', @@ -71,9 +70,7 @@ class CreateShortUrlCommandTest extends TestCase $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn( UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl), ); - $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( - 'stringified_short_url', - ); + $this->stringifier->method('stringify')->with($shortUrl)->willReturn('stringified_short_url'); $this->commandTester->setInputs([$shortUrl->getLongUrl()]); $this->commandTester->execute([]); @@ -104,9 +101,7 @@ class CreateShortUrlCommandTest extends TestCase return true; }), )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl)); - $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( - 'stringified_short_url', - ); + $this->stringifier->method('stringify')->with($shortUrl)->willReturn('stringified_short_url'); $this->commandTester->execute([ 'long-url' => 'http://domain.com/foo/bar', diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 7bbd5966..98544b77 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -26,6 +26,8 @@ class DeleteTagsCommandTest extends TestCase #[Test] public function errorIsReturnedWhenNoTagsAreProvided(): void { + $this->tagService->expects($this->never())->method('deleteTags'); + $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 2c82247f..b78dbae8 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Visit; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; @@ -31,26 +33,27 @@ use function sprintf; use const PHP_EOL; +#[AllowMockObjectsWithoutExpectations] class LocateVisitsCommandTest extends TestCase { private CommandTester $commandTester; private MockObject & VisitLocatorInterface $visitService; private MockObject & VisitToLocationHelperInterface $visitToLocation; - private MockObject & Lock\LockInterface $lock; - private MockObject & Command $downloadDbCommand; + private Stub & Lock\LockInterface $lock; + private Stub & Command $downloadDbCommand; protected function setUp(): void { $this->visitService = $this->createMock(VisitLocatorInterface::class); $this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::class); - $locker = $this->createMock(Lock\LockFactory::class); - $this->lock = $this->createMock(Lock\SharedLockInterface::class); + $locker = $this->createStub(Lock\LockFactory::class); + $this->lock = $this->createStub(Lock\SharedLockInterface::class); $locker->method('createLock')->willReturn($this->lock); $command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker); - $this->downloadDbCommand = CliTestUtils::createCommandMock(DownloadGeoLiteDbCommand::NAME); + $this->downloadDbCommand = CliTestUtils::createCommandStub(DownloadGeoLiteDbCommand::NAME); $this->commandTester = CliTestUtils::testerForCommand($command, $this->downloadDbCommand); } diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index 88b1280b..b49f53c1 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -30,8 +30,8 @@ class ApplicationFactoryTest extends TestCase 'baz' => 'baz', ], ]); - $sm->setService('foo', CliTestUtils::createCommandMock('foo')); - $sm->setService('bar', CliTestUtils::createCommandMock('bar')); + $sm->setService('foo', CliTestUtils::createCommandStub('foo')); + $sm->setService('bar', CliTestUtils::createCommandStub('bar')); $instance = ($this->factory)($sm); diff --git a/module/CLI/test/Input/InputUtilsTest.php b/module/CLI/test/Input/InputUtilsTest.php index beb882ca..183d990b 100644 --- a/module/CLI/test/Input/InputUtilsTest.php +++ b/module/CLI/test/Input/InputUtilsTest.php @@ -26,6 +26,7 @@ class InputUtilsTest extends TestCase #[TestWith([''], 'empty string')] public function processDateReturnsNullForEmptyDates(string|null $date): void { + $this->input->expects($this->never())->method('writeln'); self::assertNull(InputUtils::processDate('name', $date, $this->input)); } @@ -33,6 +34,7 @@ class InputUtilsTest extends TestCase public function processDateReturnsAtomFormatedForValidDates(): void { $date = '2025-01-20'; + $this->input->expects($this->never())->method('writeln'); self::assertEquals(Chronos::parse($date)->toAtomString(), InputUtils::processDate('name', $date, $this->input)); } diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index aed8f3c5..76e48dc8 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\RedirectRule; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -20,6 +21,7 @@ use Symfony\Component\Console\Style\StyleInterface; use function sprintf; +#[AllowMockObjectsWithoutExpectations] class RedirectRuleHandlerTest extends TestCase { private RedirectRuleHandler $handler; diff --git a/module/CLI/test/Util/CliTestUtils.php b/module/CLI/test/Util/CliTestUtils.php index bacde361..2e0e642b 100644 --- a/module/CLI/test/Util/CliTestUtils.php +++ b/module/CLI/test/Util/CliTestUtils.php @@ -6,7 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Util; use PHPUnit\Framework\Assert; use PHPUnit\Framework\MockObject\Generator\Generator; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputDefinition; @@ -14,7 +14,7 @@ use Symfony\Component\Console\Tester\CommandTester; class CliTestUtils { - public static function createCommandMock(string $name): MockObject & Command + public static function createCommandStub(string $name): Stub & Command { static $generator = null; @@ -24,7 +24,7 @@ class CliTestUtils $command = $generator->testDouble( Command::class, - mockObject: true, + mockObject: false, callOriginalConstructor: false, callOriginalClone: false, ); diff --git a/module/CLI/test/Util/ProcessRunnerTest.php b/module/CLI/test/Util/ProcessRunnerTest.php index 88253273..2454f682 100644 --- a/module/CLI/test/Util/ProcessRunnerTest.php +++ b/module/CLI/test/Util/ProcessRunnerTest.php @@ -26,7 +26,7 @@ class ProcessRunnerTest extends TestCase { $this->helper = $this->createMock(ProcessHelper::class); $this->formatter = $this->createMock(DebugFormatterHelper::class); - $helperSet = $this->createMock(HelperSet::class); + $helperSet = $this->createStub(HelperSet::class); $helperSet->method('get')->willReturn($this->formatter); $this->helper->method('getHelperSet')->willReturn($helperSet); $this->process = $this->createMock(Process::class); diff --git a/module/CLI/test/Util/ShlinkTableTest.php b/module/CLI/test/Util/ShlinkTableTest.php index e80e1f8b..54ff0bf5 100644 --- a/module/CLI/test/Util/ShlinkTableTest.php +++ b/module/CLI/test/Util/ShlinkTableTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Util; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use ReflectionObject; use Shlinkio\Shlink\CLI\Util\ShlinkTable; @@ -15,15 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface; class ShlinkTableTest extends TestCase { - private ShlinkTable $shlinkTable; - private MockObject & Table $baseTable; - - protected function setUp(): void - { - $this->baseTable = $this->createMock(Table::class); - $this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable); - } - #[Test] public function renderMakesTableToBeRenderedWithProvidedInfo(): void { @@ -32,22 +22,23 @@ class ShlinkTableTest extends TestCase $headerTitle = 'Header'; $footerTitle = 'Footer'; - $this->baseTable->expects($this->once())->method('setStyle')->with( + $baseTable = $this->createMock(Table::class); + $baseTable->expects($this->once())->method('setStyle')->with( $this->isInstanceOf(TableStyle::class), )->willReturnSelf(); - $this->baseTable->expects($this->once())->method('setHeaders')->with($headers)->willReturnSelf(); - $this->baseTable->expects($this->once())->method('setRows')->with($rows)->willReturnSelf(); - $this->baseTable->expects($this->once())->method('setFooterTitle')->with($footerTitle)->willReturnSelf(); - $this->baseTable->expects($this->once())->method('setHeaderTitle')->with($headerTitle)->willReturnSelf(); - $this->baseTable->expects($this->once())->method('render')->with()->willReturnSelf(); + $baseTable->expects($this->once())->method('setHeaders')->with($headers)->willReturnSelf(); + $baseTable->expects($this->once())->method('setRows')->with($rows)->willReturnSelf(); + $baseTable->expects($this->once())->method('setFooterTitle')->with($footerTitle)->willReturnSelf(); + $baseTable->expects($this->once())->method('setHeaderTitle')->with($headerTitle)->willReturnSelf(); + $baseTable->expects($this->once())->method('render')->with()->willReturnSelf(); - $this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle); + ShlinkTable::fromBaseTable($baseTable)->render($headers, $rows, $footerTitle, $headerTitle); } #[Test] public function newTableIsCreatedForFactoryMethod(): void { - $instance = ShlinkTable::default($this->createMock(OutputInterface::class)); + $instance = ShlinkTable::default($this->createStub(OutputInterface::class)); $ref = new ReflectionObject($instance); $baseTable = $ref->getProperty('baseTable'); diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index e78df177..9eee25a8 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -47,7 +47,7 @@ class PixelActionTest extends TestCase $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, null), ); - $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); + $response = $this->action->process($request, $this->createStub(RequestHandlerInterface::class)); self::assertInstanceOf(PixelResponse::class, $response); self::assertEquals(200, $response->getStatusCode()); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index c9f9eaa7..98cf6216 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -36,7 +36,7 @@ class RedirectActionTest extends TestCase $this->requestTracker = $this->createMock(RequestTrackerInterface::class); $this->redirectRespHelper = $this->createMock(RedirectResponseHelperInterface::class); - $redirectBuilder = $this->createMock(ShortUrlRedirectionBuilderInterface::class); + $redirectBuilder = $this->createStub(ShortUrlRedirectionBuilderInterface::class); $redirectBuilder->method('buildShortUrlRedirect')->willReturn(self::LONG_URL); $this->action = new RedirectAction( @@ -66,7 +66,7 @@ class RedirectActionTest extends TestCase self::LONG_URL, )->willReturn($expectedResp); - $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); + $response = $this->action->process($request, $this->createStub(RequestHandlerInterface::class)); self::assertSame($expectedResp, $response); } @@ -79,11 +79,12 @@ class RedirectActionTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); $this->requestTracker->expects($this->never())->method('trackIfApplicable'); + $this->redirectRespHelper->expects($this->never())->method('buildRedirectResponse'); $handler = $this->createMock(RequestHandlerInterface::class); $handler->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $request = new ServerRequest()->withAttribute('shortCode', $shortCode); $this->action->process($request, $handler); } } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index 796cc3bb..adff4723 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -24,6 +25,7 @@ use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use function Laminas\Stratigility\middleware; +#[AllowMockObjectsWithoutExpectations] class NotFoundRedirectResolverTest extends TestCase { private NotFoundRedirectResolver $resolver; diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index f4836711..1677022a 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Domain; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; @@ -24,12 +26,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class DomainServiceTest extends TestCase { private DomainService $domainService; - private MockObject & EntityManagerInterface $em; + private Stub & EntityManagerInterface $em; private MockObject & DomainRepositoryInterface $repo; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); + $this->em = $this->createStub(EntityManagerInterface::class); $this->repo = $this->createMock(DomainRepositoryInterface::class); $this->domainService = new DomainService( $this->em, @@ -106,21 +108,21 @@ class DomainServiceTest extends TestCase ]; } - #[Test] + #[Test, AllowMockObjectsWithoutExpectations] public function getDomainThrowsExceptionWhenDomainIsNotFound(): void { - $this->em->expects($this->once())->method('find')->with(Domain::class, '123')->willReturn(null); + $this->em->method('find')->with(Domain::class, '123')->willReturn(null); $this->expectException(DomainNotFoundException::class); $this->domainService->getDomain('123'); } - #[Test] + #[Test, AllowMockObjectsWithoutExpectations] public function getDomainReturnsEntityWhenFound(): void { $domain = Domain::withAuthority(''); - $this->em->expects($this->once())->method('find')->with(Domain::class, '123')->willReturn($domain); + $this->em->method('find')->with(Domain::class, '123')->willReturn($domain); $result = $this->domainService->getDomain('123'); @@ -134,8 +136,7 @@ class DomainServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( $foundDomain, ); - $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); - $this->em->expects($this->once())->method('flush'); + $this->em->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); $result = $this->domainService->getOrCreate($authority, $apiKey); @@ -152,8 +153,6 @@ class DomainServiceTest extends TestCase $domain->setId('1'); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); - $this->em->expects($this->never())->method('persist'); - $this->em->expects($this->never())->method('flush'); $this->expectException(DomainNotFoundException::class); @@ -169,8 +168,7 @@ class DomainServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( $foundDomain, ); - $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); - $this->em->expects($this->once())->method('flush'); + $this->em->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); $result = $this->domainService->configureNotFoundRedirects($authority, NotFoundRedirects::withRedirects( 'foo.com', diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 25d9952f..1826572a 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -42,7 +42,7 @@ class NotFoundRedirectHandlerTest extends TestCase $this->next = $this->createMock(RequestHandlerInterface::class); $this->req = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, - $this->createMock(NotFoundType::class), + $this->createStub(NotFoundType::class), ); } diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 9df12a6d..5b95f2de 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -33,7 +33,7 @@ class NotFoundTrackerMiddlewareTest extends TestCase $this->request = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, - $this->createMock(NotFoundType::class), + $this->createStub(NotFoundType::class), ); } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php index 8273c2ea..7c328734 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php @@ -34,7 +34,7 @@ class CloseDbConnectionEventListenerDelegatorTest extends TestCase }; $this->container->expects($this->once())->method('get')->with('em')->willReturn( - $this->createMock(ReopeningEntityManagerInterface::class), + $this->createStub(ReopeningEntityManagerInterface::class), ); ($this->delegator)($this->container, '', $callback); diff --git a/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php b/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php index eb66f49e..b1092c8f 100644 --- a/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php +++ b/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php @@ -29,7 +29,7 @@ class RequestIdProviderTest extends TestCase $initialId = $this->middleware->currentRequestId(); self::assertEquals($initialId, $this->provider->currentRequestId()); - $handler = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createStub(RequestHandlerInterface::class); $handler->method('handle')->willReturn(new Response()); $this->middleware->process(ServerRequestFactory::fromGlobals(), $handler); $idAfterProcessingRequest = $this->middleware->currentRequestId(); diff --git a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php index d8168f6e..5639deb2 100644 --- a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,6 +16,7 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Model\Location; +#[AllowMockObjectsWithoutExpectations] class LocateUnlocatedVisitsTest extends TestCase { private LocateUnlocatedVisits $listener; diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index 8a4c1b7d..7aaff7c9 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -83,7 +83,7 @@ class SendVisitToMatomoTest extends TestCase $e = new Exception('Error!'); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( - $this->createMock(Visit::class), + $this->createStub(Visit::class), ); $this->visitSender->expects($this->once())->method('sendVisit')->willThrowException($e); $this->logger->expects($this->never())->method('warning'); diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php index 3294e929..5705d473 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -93,11 +93,12 @@ class NotifyNewShortUrlToMercureTest extends TestCase public function publishingIsSkippedIfNewShortUrlTopicIsNotEnabled(): void { $shortUrl = ShortUrl::withLongUrl('https://longUrl'); - $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn($shortUrl); $this->updatesGenerator->expects($this->never())->method('newShortUrlUpdate'); $this->helper->expects($this->never())->method('publishUpdate'); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); $this->listener(enableShortUrlTopic: false)(new ShortUrlCreated('123')); } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php index e4351c36..84b88036 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -46,6 +46,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $this->em->expects($this->never())->method('find'); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->never())->method('newShortUrlUpdate'); ($this->listener(false))(new ShortUrlCreated('123')); } @@ -61,6 +62,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase ); $this->logger->expects($this->never())->method('debug'); $this->helper->expects($this->never())->method('publishUpdate'); + $this->updatesGenerator->expects($this->never())->method('newShortUrlUpdate'); ($this->listener())(new ShortUrlCreated($shortUrlId)); } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 78abb5c7..6dc1b7cb 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -52,6 +52,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->em->expects($this->never())->method('find'); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); ($this->listener(new RabbitMqOptions(enabled: false)))(new UrlVisited('123')); } @@ -67,6 +68,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ); $this->logger->expects($this->never())->method('debug'); $this->helper->expects($this->never())->method('publishUpdate'); + $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); ($this->listener())(new UrlVisited($visitId)); } @@ -145,6 +147,8 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); $setup($this->updatesGenerator); $expect($this->helper, $this->updatesGenerator); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); ($this->listener())(new UrlVisited($visitId)); } diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php index 412f603e..a67c18ca 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php @@ -45,6 +45,7 @@ class NotifyNewShortUrlToRedisTest extends TestCase $this->em->expects($this->never())->method('find'); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->never())->method('newShortUrlUpdate'); $this->createListener(false)(new ShortUrlCreated('123')); } diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php index d14ab631..90843a5b 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -45,6 +45,7 @@ class NotifyVisitToRedisTest extends TestCase $this->em->expects($this->never())->method('find'); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); $this->createListener(false)(new UrlVisited('123')); } diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index 5288aa1b..c5f85e86 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -125,6 +125,7 @@ class UpdateGeoLiteDbTest extends TestCase $this->eventDispatcher->expects($this->exactly($expectedDispatches))->method('dispatch')->with( new GeoLiteDbCreated(), ); + $this->logger->expects($this->never())->method('warning'); ($this->listener)(); } diff --git a/module/Core/test/Geolocation/GeolocationDbUpdaterTest.php b/module/Core/test/Geolocation/GeolocationDbUpdaterTest.php index 88e256ae..d0abfc33 100644 --- a/module/Core/test/Geolocation/GeolocationDbUpdaterTest.php +++ b/module/Core/test/Geolocation/GeolocationDbUpdaterTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use Closure; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -28,6 +29,7 @@ use Throwable; use function array_map; use function range; +#[AllowMockObjectsWithoutExpectations] class GeolocationDbUpdaterTest extends TestCase { private MockObject & DbUpdaterInterface $dbUpdater; diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index 84229728..ac82e1a4 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -43,6 +44,7 @@ use function Shlinkio\Shlink\Core\ArrayUtils\some; use function sprintf; use function str_contains; +#[AllowMockObjectsWithoutExpectations] class ImportedLinksProcessorTest extends TestCase { private ImportedLinksProcessor $processor; diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 0acc535b..0693f8aa 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -9,6 +9,7 @@ use MatomoTracker; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; @@ -29,13 +30,13 @@ use function array_values; class MatomoVisitSenderTest extends TestCase { private MockObject & MatomoTrackerBuilderInterface $trackerBuilder; - private MockObject & VisitIterationRepositoryInterface $visitIterationRepository; + private Stub & VisitIterationRepositoryInterface $visitIterationRepository; private MatomoVisitSender $visitSender; protected function setUp(): void { $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); - $this->visitIterationRepository = $this->createMock(VisitIterationRepositoryInterface::class); + $this->visitIterationRepository = $this->createStub(VisitIterationRepositoryInterface::class); $this->visitSender = new MatomoVisitSender( $this->trackerBuilder, @@ -155,13 +156,13 @@ class MatomoVisitSenderTest extends TestCase $visitor = Visitor::empty(); $bot = Visitor::botInstance(); - $this->visitIterationRepository->expects($this->once())->method('findAllVisits')->with($dateRange)->willReturn([ + $this->visitIterationRepository->method('findAllVisits')->with($dateRange)->willReturn([ Visit::forBasePath($bot), Visit::forValidShortUrl(ShortUrl::createFake(), $visitor), Visit::forInvalidShortUrl($visitor), ]); - $tracker = $this->createMock(MatomoTracker::class); + $tracker = $this->createStub(MatomoTracker::class); $tracker->method('setUrl')->willReturn($tracker); $tracker->method('setUserAgent')->willReturn($tracker); $tracker->method('setUrlReferrer')->willReturn($tracker); diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index 73feece2..fb596b17 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -6,8 +6,10 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\Options\DeleteShortUrlsOptions; use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; @@ -24,10 +26,11 @@ use function array_map; use function range; use function sprintf; +#[AllowMockObjectsWithoutExpectations] class DeleteShortUrlServiceTest extends TestCase { private MockObject & EntityManagerInterface $em; - private MockObject & ShortUrlResolverInterface $urlResolver; + private Stub & ShortUrlResolverInterface $urlResolver; private MockObject & ExpiredShortUrlsRepository $expiredShortUrlsRepository; private string $shortCode; @@ -40,7 +43,7 @@ class DeleteShortUrlServiceTest extends TestCase $this->em = $this->createMock(EntityManagerInterface::class); - $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->urlResolver = $this->createStub(ShortUrlResolverInterface::class); $this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl); $this->expiredShortUrlsRepository = $this->createMock(ExpiredShortUrlsRepository::class); @@ -51,6 +54,7 @@ class DeleteShortUrlServiceTest extends TestCase { $service = $this->createService(); + $this->em->expects($this->never())->method('remove'); $this->expectException(DeleteShortUrlException::class); $this->expectExceptionMessage(sprintf( 'Impossible to delete short URL with short code "%s", since it has more than "5" visits.', @@ -91,10 +95,8 @@ class DeleteShortUrlServiceTest extends TestCase { $service = $this->createService(true, 100); - $this->em->expects($this->once())->method('remove')->with($this->isInstanceOf(ShortUrl::class))->willReturn( - null, - ); - $this->em->expects($this->once())->method('flush')->with()->willReturn(null); + $this->em->expects($this->once())->method('remove')->with($this->isInstanceOf(ShortUrl::class)); + $this->em->expects($this->once())->method('flush'); $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index 1c55bc0d..857c4291 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -39,6 +39,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); $this->httpClient->expects($this->never())->method('request'); + $this->logger->expects($this->never())->method('warning'); $result = $this->helper()->processTitle($data); @@ -53,6 +54,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase 'title' => 'foo', ]); $this->httpClient->expects($this->never())->method('request'); + $this->logger->expects($this->never())->method('warning'); $result = $this->helper(autoResolveTitles: true)->processTitle($data); @@ -64,6 +66,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); $this->expectRequestToBeCalled()->willThrowException(new Exception('Error')); + $this->logger->expects($this->never())->method('warning'); $result = $this->helper(autoResolveTitles: true)->processTitle($data); @@ -75,6 +78,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); $this->expectRequestToBeCalled()->willReturn(new JsonResponse(['foo' => 'bar'])); + $this->logger->expects($this->never())->method('warning'); $result = $this->helper(autoResolveTitles: true)->processTitle($data); @@ -86,6 +90,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); $this->expectRequestToBeCalled()->willReturn($this->respWithoutTitle()); + $this->logger->expects($this->never())->method('warning'); $result = $this->helper(autoResolveTitles: true)->processTitle($data); @@ -151,8 +156,6 @@ class ShortUrlTitleResolutionHelperTest extends TestCase self::assertEquals('Resolved "title"', $result->title); } - /** - */ private function expectRequestToBeCalled(): InvocationStubber { return $this->httpClient->expects($this->once())->method('request')->with( diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 0578c1f8..50c2ddf2 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -40,7 +41,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase private MockObject & RequestTrackerInterface $requestTracker; private MockObject & ShortUrlRedirectionBuilderInterface $redirectionBuilder; private MockObject & RedirectResponseHelperInterface $redirectResponseHelper; - private MockObject & RequestHandlerInterface $handler; + private Stub & RequestHandlerInterface $handler; protected function setUp(): void { @@ -48,7 +49,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $this->requestTracker = $this->createMock(RequestTrackerInterface::class); $this->redirectionBuilder = $this->createMock(ShortUrlRedirectionBuilderInterface::class); $this->redirectResponseHelper = $this->createMock(RedirectResponseHelperInterface::class); - $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->handler = $this->createStub(RequestHandlerInterface::class); $this->handler->method('handle')->willReturn(new RedirectResponse('')); } @@ -66,7 +67,6 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $this->requestTracker->expects($this->never())->method('trackIfApplicable'); $this->redirectionBuilder->expects($this->never())->method('buildShortUrlRedirect'); $this->redirectResponseHelper->expects($this->never())->method('buildRedirectResponse'); - $this->handler->expects($this->once())->method('handle'); $this->middleware($options)->process($request, $this->handler); } @@ -116,7 +116,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase extraPathMode: ExtraPathMode::APPEND, ); - $type = $this->createMock(NotFoundType::class); + $type = $this->createStub(NotFoundType::class); $type->method('isRegularNotFound')->willReturn(true); $type->method('isInvalidShortUrl')->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) @@ -144,7 +144,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase extraPathMode: $extraPathMode, ); - $type = $this->createMock(NotFoundType::class); + $type = $this->createStub(NotFoundType::class); $type->method('isRegularNotFound')->willReturn(true); $type->method('isInvalidShortUrl')->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 4968ccbf..b2f4de8c 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -116,7 +116,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase { $repo = $this->createMock(DomainRepository::class); $repo->expects($this->exactly(3))->method('findOneBy')->with($this->isArray())->willReturn(null); - $this->em->method('getRepository')->willReturn($repo); + $this->em->expects($this->atLeastOnce())->method('getRepository')->willReturn($repo); $authority = 'foo.com'; $domain1 = $this->resolver->resolveDomain($authority); @@ -135,7 +135,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase { $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly(6))->method('findOneBy')->with($this->isArray())->willReturn(null); - $this->em->method('getRepository')->willReturn($tagRepo); + $this->em->expects($this->atLeastOnce())->method('getRepository')->willReturn($tagRepo); $tags = ['foo', 'bar']; [$foo1, $bar1] = $this->resolver->resolveTags($tags); diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index c3554363..51ef50ef 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -29,15 +29,11 @@ class ShortUrlServiceTest extends TestCase protected function setUp(): void { - $em = $this->createMock(EntityManagerInterface::class); - $em->method('persist')->willReturn(null); - $em->method('flush')->willReturn(null); - $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); $this->service = new ShortUrlService( - $em, + $this->createStub(EntityManagerInterface::class), $this->urlResolver, $this->titleResolutionHelper, new SimpleShortUrlRelationResolver(), diff --git a/module/Core/test/ShortUrl/UrlShortenerTest.php b/module/Core/test/ShortUrl/UrlShortenerTest.php index f2467dbd..8e78ff18 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -21,10 +22,10 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\UrlShortener; +#[AllowMockObjectsWithoutExpectations] class UrlShortenerTest extends TestCase { private UrlShortener $urlShortener; - private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; private MockObject & EventDispatcherInterface $dispatcher; @@ -35,16 +36,16 @@ class UrlShortenerTest extends TestCase $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); $this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class); - $this->em = $this->createMock(EntityManagerInterface::class); - $this->em->method('persist')->willReturnCallback(fn (ShortUrl $shortUrl) => $shortUrl->setId('10')); - $this->em->method('wrapInTransaction')->willReturnCallback(fn (callable $callback) => $callback()); + $em = $this->createStub(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(fn (ShortUrl $shortUrl) => $shortUrl->setId('10')); + $em->method('wrapInTransaction')->willReturnCallback(fn (callable $callback) => $callback()); $this->dispatcher = $this->createMock(EventDispatcherInterface::class); $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); $this->urlShortener = new UrlShortener( $this->titleResolutionHelper, - $this->em, + $em, new SimpleShortUrlRelationResolver(), $this->shortCodeHelper, $this->dispatcher, diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 4080986f..2d32c509 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -28,15 +28,12 @@ use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; class TagServiceTest extends TestCase { private TagService $service; - private MockObject & EntityManagerInterface $em; private MockObject & TagRepository $repo; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(TagRepository::class); - - $this->service = new TagService($this->em, $this->repo); + $this->service = new TagService($this->createStub(EntityManagerInterface::class), $this->repo); } #[Test] @@ -136,7 +133,6 @@ class TagServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->willReturn($expected); $this->repo->expects($this->exactly($count > 0 ? 0 : 1))->method('count')->willReturn($count); - $this->em->expects($this->once())->method('flush'); $tag = $this->service->renameTag(Renaming::fromNames($oldName, $newName)); @@ -155,7 +151,6 @@ class TagServiceTest extends TestCase { $this->repo->expects($this->once())->method('findOneBy')->willReturn(new Tag('foo')); $this->repo->expects($this->once())->method('count')->willReturn(1); - $this->em->expects($this->never())->method('flush'); $this->expectException(TagConflictException::class); diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php index e7c2ef16..ac6206fb 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -52,6 +52,9 @@ class RequestTrackerTest extends TestCase public function trackingIsDisabledWhenRequestDoesNotMeetConditions(ServerRequestInterface $request): void { $this->visitsTracker->expects($this->never())->method('track'); + $this->notFoundType->expects($this->never())->method('isBaseUrl'); + $this->notFoundType->expects($this->never())->method('isRegularNotFound'); + $this->notFoundType->expects($this->never())->method('isInvalidShortUrl'); $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $this->requestTracker->trackIfApplicable($shortUrl, $request); @@ -89,6 +92,9 @@ class RequestTrackerTest extends TestCase $shortUrl, $this->isInstanceOf(Visitor::class), ); + $this->notFoundType->expects($this->never())->method('isBaseUrl'); + $this->notFoundType->expects($this->never())->method('isRegularNotFound'); + $this->notFoundType->expects($this->never())->method('isInvalidShortUrl'); $this->requestTracker->trackIfApplicable($shortUrl, $this->request); } @@ -101,6 +107,9 @@ class RequestTrackerTest extends TestCase $shortUrl, $this->isInstanceOf(Visitor::class), ); + $this->notFoundType->expects($this->never())->method('isBaseUrl'); + $this->notFoundType->expects($this->never())->method('isRegularNotFound'); + $this->notFoundType->expects($this->never())->method('isInvalidShortUrl'); $this->requestTracker->trackIfApplicable($shortUrl, ServerRequestFactory::fromGlobals()->withAttribute( IP_ADDRESS_REQUEST_ATTRIBUTE, @@ -159,6 +168,9 @@ class RequestTrackerTest extends TestCase $this->visitsTracker->expects($this->never())->method('trackBaseUrlVisit'); $this->visitsTracker->expects($this->never())->method('trackRegularNotFoundVisit'); $this->visitsTracker->expects($this->never())->method('trackInvalidShortUrlVisit'); + $this->notFoundType->expects($this->never())->method('isBaseUrl'); + $this->notFoundType->expects($this->never())->method('isRegularNotFound'); + $this->notFoundType->expects($this->never())->method('isInvalidShortUrl'); $this->requestTracker->trackNotFoundIfApplicable($request); } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 68aa4310..f87e35ec 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -110,7 +110,7 @@ class VisitsStatsHelperTest extends TestCase static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); - $repo2 = $this->createMock(VisitRepository::class); + $repo2 = $this->createStub(VisitRepository::class); $repo2->method('findVisitsByShortCode')->willReturn($list); $repo2->method('countVisitsByShortCode')->willReturn(1); @@ -164,7 +164,7 @@ class VisitsStatsHelperTest extends TestCase static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); - $repo2 = $this->createMock(VisitRepository::class); + $repo2 = $this->createStub(VisitRepository::class); $repo2->method('findVisitsByTag')->willReturn($list); $repo2->method('countVisitsByTag')->willReturn(1); @@ -203,7 +203,7 @@ class VisitsStatsHelperTest extends TestCase static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); - $repo2 = $this->createMock(VisitRepository::class); + $repo2 = $this->createStub(VisitRepository::class); $repo2->method('findVisitsByDomain')->willReturn($list); $repo2->method('countVisitsByDomain')->willReturn(1); @@ -227,7 +227,7 @@ class VisitsStatsHelperTest extends TestCase static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); - $repo2 = $this->createMock(VisitRepository::class); + $repo2 = $this->createStub(VisitRepository::class); $repo2->method('findVisitsByDomain')->willReturn($list); $repo2->method('countVisitsByDomain')->willReturn(1); diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php index aae90f49..cd78620e 100644 --- a/module/Rest/test/Action/HealthActionTest.php +++ b/module/Rest/test/Action/HealthActionTest.php @@ -25,11 +25,11 @@ class HealthActionTest extends TestCase protected function setUp(): void { $this->conn = $this->createMock(Connection::class); - $dbPlatform = $this->createMock(AbstractPlatform::class); + $dbPlatform = $this->createStub(AbstractPlatform::class); $dbPlatform->method('getDummySelectSQL')->willReturn(''); $this->conn->method('getDatabasePlatform')->willReturn($dbPlatform); - $em = $this->createMock(EntityManagerInterface::class); + $em = $this->createStub(EntityManagerInterface::class); $em->method('getConnection')->willReturn($this->conn); $this->action = new HealthAction($em, new AppOptions(version: '1.2.3')); @@ -38,7 +38,7 @@ class HealthActionTest extends TestCase #[Test] public function passResponseIsReturnedWhenDummyQuerySucceeds(): void { - $this->conn->expects($this->once())->method('executeQuery')->willReturn($this->createMock(Result::class)); + $this->conn->expects($this->once())->method('executeQuery')->willReturn($this->createStub(Result::class)); /** @var JsonResponse $resp */ $resp = $this->action->handle(new ServerRequest()); diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index ddd43338..e014b948 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -78,7 +79,7 @@ class MercureInfoActionTest extends TestCase yield 'days defined' => [10]; } - #[Test] + #[Test, AllowMockObjectsWithoutExpectations] public function getRouteDefReturnsExpectedData(): void { self::assertEquals([ diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index d28c3b49..7c9510ad 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -25,7 +25,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase protected function setUp(): void { $this->urlShortener = $this->createMock(UrlShortenerInterface::class); - $transformer = $this->createMock(ShortUrlDataTransformerInterface::class); + $transformer = $this->createStub(ShortUrlDataTransformerInterface::class); $transformer->method('transform')->willReturn([]); $this->action = new SingleStepCreateShortUrlAction( diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index f83ee037..f2629bc1 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -33,6 +33,7 @@ class UpdateTagActionTest extends TestCase { $request = $this->requestWithApiKey()->withParsedBody($bodyParams); + $this->tagService->expects($this->never())->method('renameTag'); $this->expectException(ValidationException::class); $this->action->handle($request); diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index 6892d3bd..d0799f95 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -55,7 +55,9 @@ class OrphanVisitsActionTest extends TestCase #[Test] public function exceptionIsThrownIfInvalidDataIsProvided(): void { + $this->visitsHelper->expects($this->never())->method('orphanVisits'); $this->expectException(ValidationException::class); + $this->action->handle( ServerRequestFactory::fromGlobals() ->withAttribute(ApiKey::class, ApiKey::create()) diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 0b3abe3f..2f7b6177 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -48,7 +48,7 @@ class CrossDomainMiddlewareTest extends TestCase $originalResponse = new Response(); $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); - $response = $this->middleware()->process((new ServerRequest())->withHeader('Origin', 'local'), $this->handler); + $response = $this->middleware()->process(new ServerRequest()->withHeader('Origin', 'local'), $this->handler); self::assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); @@ -63,7 +63,7 @@ class CrossDomainMiddlewareTest extends TestCase public function optionsRequestIncludesMoreHeaders(): void { $originalResponse = new Response(); - $request = (new ServerRequest()) + $request = new ServerRequest() ->withMethod('OPTIONS') ->withHeader('Origin', 'local') ->withHeader('Access-Control-Request-Headers', 'foo, bar, baz'); @@ -90,8 +90,8 @@ class CrossDomainMiddlewareTest extends TestCase if ($allowHeader !== null) { $originalResponse = $originalResponse->withHeader('Allow', $allowHeader); } - $request = (new ServerRequest())->withHeader('Origin', 'local') - ->withMethod('OPTIONS'); + $request = new ServerRequest()->withHeader('Origin', 'local') + ->withMethod('OPTIONS'); $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); $response = $this->middleware()->process($request, $this->handler); @@ -113,9 +113,9 @@ class CrossDomainMiddlewareTest extends TestCase int $status, int $expectedStatus, ): void { - $originalResponse = (new Response())->withStatus($status); - $request = (new ServerRequest())->withMethod($method) - ->withHeader('Origin', 'local'); + $originalResponse = new Response()->withStatus($status); + $request = new ServerRequest()->withMethod($method) + ->withHeader('Origin', 'local'); $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); $response = $this->middleware()->process($request, $this->handler); @@ -152,10 +152,10 @@ class CrossDomainMiddlewareTest extends TestCase public function credentialsAreAllowedIfConfiguredSo(bool $allowCredentials, string $method): void { $originalResponse = new Response(); - $request = (new ServerRequest()) + $request = new ServerRequest() ->withMethod($method) ->withHeader('Origin', 'local'); - $this->handler->method('handle')->willReturn($originalResponse); + $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); $response = $this->middleware(allowCredentials: $allowCredentials)->process($request, $this->handler); $headers = $response->getHeaders(); diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index de438a44..a7158e15 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -30,7 +30,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase public function whenNoJsonResponseIsReturnedNoFurtherOperationsArePerformed(): void { $expectedResp = new Response(); - $this->requestHandler->method('handle')->willReturn($expectedResp); + $this->requestHandler->expects($this->once())->method('handle')->willReturn($expectedResp); $resp = $this->middleware->process(new ServerRequest(), $this->requestHandler); @@ -40,7 +40,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase #[Test, DataProvider('provideData')] public function properResponseIsReturned(string|null $accept, array $query, string $expectedContentType): void { - $request = (new ServerRequest())->withQueryParams($query); + $request = new ServerRequest()->withQueryParams($query); if ($accept !== null) { $request = $request->withHeader('Accept', $accept); } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 3efe3f43..c3d91348 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Service; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManager; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; @@ -24,6 +25,7 @@ use Shlinkio\Shlink\Rest\Service\ApiKeyService; use function substr; +#[AllowMockObjectsWithoutExpectations] class ApiKeyServiceTest extends TestCase { private ApiKeyService $service; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4c7b9f10..76df50d3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,6 +7,8 @@ cacheDirectory="build/.phpunit/unit-tests.cache" displayDetailsOnTestsThatTriggerWarnings="true" displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnPhpunitNotices="true" + displayDetailsOnTestsThatTriggerNotices="true" > From 96d122bcbf27c39db61323747fe5fc5595578bb7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 14:55:06 +0100 Subject: [PATCH 40/71] Decouple LocateVisitsCommand from AbstractLockedCommand --- .../Command/Db/AbstractDatabaseCommand.php | 6 ++-- .../Command/Util/AbstractLockedCommand.php | 2 +- module/CLI/src/Command/Util/CommandUtils.php | 30 +++++++++++++++++++ ...LockedCommandConfig.php => LockConfig.php} | 12 ++++---- .../src/Command/Visit/LocateVisitsCommand.php | 28 ++++++++++------- module/CLI/src/Util/ProcessRunner.php | 4 +-- 6 files changed, 59 insertions(+), 23 deletions(-) rename module/CLI/src/Command/Util/{LockedCommandConfig.php => LockConfig.php} (56%) diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index a85cb999..5212cf3b 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; -use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Lock\LockFactory; @@ -30,8 +30,8 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand $this->processRunner->run($output, $command); } - protected function getLockConfig(): LockedCommandConfig + protected function getLockConfig(): LockConfig { - return LockedCommandConfig::blocking($this->getName() ?? static::class); + return LockConfig::blocking($this->getName() ?? static::class); } } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index a4c3ef5d..bf58fb1b 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -39,5 +39,5 @@ abstract class AbstractLockedCommand extends Command abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int; - abstract protected function getLockConfig(): LockedCommandConfig; + abstract protected function getLockConfig(): LockConfig; } diff --git a/module/CLI/src/Command/Util/CommandUtils.php b/module/CLI/src/Command/Util/CommandUtils.php index 76085f1a..69158275 100644 --- a/module/CLI/src/Command/Util/CommandUtils.php +++ b/module/CLI/src/Command/Util/CommandUtils.php @@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\CLI\Command\Util; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Lock\LockFactory; + +use function sprintf; class CommandUtils { @@ -25,4 +28,31 @@ class CommandUtils return $callback(); } + + /** + * Runs a callback with a lock, making sure the lock is released after running the callback, and the callback does + * not run if the lock is already acquired. + * + * @param callable(): int $callback + */ + public static function executeWithLock( + LockFactory $locker, + LockConfig $lockConfig, + SymfonyStyle $io, + callable $callback, + ): int { + $lock = $locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); + if (! $lock->acquire($lockConfig->isBlocking)) { + $io->writeln( + sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName), + ); + return Command::INVALID; + } + + try { + return $callback(); + } finally { + $lock->release(); + } + } } diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockConfig.php similarity index 56% rename from module/CLI/src/Command/Util/LockedCommandConfig.php rename to module/CLI/src/Command/Util/LockConfig.php index a8834d92..8f8fb09c 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockConfig.php @@ -4,24 +4,24 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Util; -final class LockedCommandConfig +final readonly class LockConfig { public const float DEFAULT_TTL = 600.0; // 10 minutes private function __construct( - public readonly string $lockName, - public readonly bool $isBlocking, - public readonly float $ttl = self::DEFAULT_TTL, + public string $lockName, + public bool $isBlocking, + public float $ttl = self::DEFAULT_TTL, ) { } public static function blocking(string $lockName): self { - return new self($lockName, true); + return new self($lockName, isBlocking: true); } public static function nonBlocking(string $lockName): self { - return new self($lockName, false); + return new self($lockName, isBlocking: false); } } diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 3ed2edf9..f2501a34 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; -use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; @@ -26,7 +27,7 @@ use Throwable; use function sprintf; -class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface +class LocateVisitsCommand extends Command implements VisitGeolocationHelperInterface { public const string NAME = 'visit:locate'; @@ -35,9 +36,9 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat public function __construct( private readonly VisitLocatorInterface $visitLocator, private readonly VisitToLocationHelperInterface $visitToLocation, - LockFactory $locker, + private readonly LockFactory $locker, ) { - parent::__construct($locker); + parent::__construct(); } protected function configure(): void @@ -97,7 +98,17 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat return $this->io->confirm('Do you want to proceed?', false); } - protected function lockedExecute(InputInterface $input, OutputInterface $output): int + protected function execute(InputInterface $input, OutputInterface $output): int + { + return CommandUtils::executeWithLock( + $this->locker, + LockConfig::nonBlocking(self::NAME), + new SymfonyStyle($input, $output), + fn () => $this->runStuff($input), + ); + } + + private function runStuff(InputInterface $input): int { $retry = $input->getOption('retry'); $all = $retry && $input->getOption('all'); @@ -174,9 +185,4 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); } } - - protected function getLockConfig(): LockedCommandConfig - { - return LockedCommandConfig::nonBlocking(self::NAME); - } } diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index af9577ea..7e650f9d 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Util; use Closure; -use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Symfony\Component\Console\Helper\DebugFormatterHelper; use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Output\ConsoleOutputInterface; @@ -24,7 +24,7 @@ class ProcessRunner implements ProcessRunnerInterface { $this->createProcess = $createProcess !== null ? $createProcess(...) - : static fn (array $cmd) => new Process($cmd, timeout: LockedCommandConfig::DEFAULT_TTL); + : static fn (array $cmd) => new Process($cmd, timeout: LockConfig::DEFAULT_TTL); } public function run(OutputInterface $output, array $cmd): void From e261bd16e45c9fc252d1953b5bfb542ee9a5fb43 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 15:01:00 +0100 Subject: [PATCH 41/71] Decouple AbstractDatabaseCommand from AbstractLockedCommand --- .../Command/Db/AbstractDatabaseCommand.php | 22 +++++++--- .../Command/Util/AbstractLockedCommand.php | 43 ------------------- .../src/Command/Visit/LocateVisitsCommand.php | 4 +- 3 files changed, 18 insertions(+), 51 deletions(-) delete mode 100644 module/CLI/src/Command/Util/AbstractLockedCommand.php diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index 5212cf3b..a38abc72 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -4,23 +4,26 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; -use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; -abstract class AbstractDatabaseCommand extends AbstractLockedCommand +abstract class AbstractDatabaseCommand extends Command { private string $phpBinary; public function __construct( - LockFactory $locker, + private readonly LockFactory $locker, private readonly ProcessRunnerInterface $processRunner, PhpExecutableFinder $phpFinder, ) { - parent::__construct($locker); + parent::__construct(); $this->phpBinary = $phpFinder->find(false) ?: 'php'; } @@ -30,8 +33,15 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand $this->processRunner->run($output, $command); } - protected function getLockConfig(): LockConfig + final protected function execute(InputInterface $input, OutputInterface $output): int { - return LockConfig::blocking($this->getName() ?? static::class); + return CommandUtils::executeWithLock( + $this->locker, + LockConfig::blocking($this->getName() ?? static::class), + new SymfonyStyle($input, $output), + fn () => $this->lockedExecute($input, $output), + ); } + + abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int; } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php deleted file mode 100644 index bf58fb1b..00000000 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ /dev/null @@ -1,43 +0,0 @@ -getLockConfig(); - $lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); - - if (! $lock->acquire($lockConfig->isBlocking)) { - $output->writeln( - sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName), - ); - return self::INVALID; - } - - try { - return $this->lockedExecute($input, $output); - } finally { - $lock->release(); - } - } - - abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int; - - abstract protected function getLockConfig(): LockConfig; -} diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index f2501a34..fa1b13b4 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -104,11 +104,11 @@ class LocateVisitsCommand extends Command implements VisitGeolocationHelperInter $this->locker, LockConfig::nonBlocking(self::NAME), new SymfonyStyle($input, $output), - fn () => $this->runStuff($input), + fn () => $this->locateVisits($input), ); } - private function runStuff(InputInterface $input): int + private function locateVisits(InputInterface $input): int { $retry = $input->getOption('retry'); $all = $retry && $input->getOption('all'); From fff070ea8717365e2764b6fb4cbb7e330606d3a6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 Dec 2025 15:24:01 +0100 Subject: [PATCH 42/71] Convert LocateVisitsCommand into invokable command --- .../src/Command/Visit/LocateVisitsCommand.php | 81 +++++++------------ 1 file changed, 31 insertions(+), 50 deletions(-) diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index fa1b13b4..d6ce9ea2 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -15,18 +15,21 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\ArrayInput; -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 Symfony\Component\Lock\LockFactory; use Throwable; use function sprintf; +#[AsCommand( + name: LocateVisitsCommand::NAME, + description: 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed', +)] class LocateVisitsCommand extends Command implements VisitGeolocationHelperInterface { public const string NAME = 'visit:locate'; @@ -41,41 +44,25 @@ class LocateVisitsCommand extends Command implements VisitGeolocationHelperInter parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription( - 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.', - ) - ->addOption( - 'retry', - 'r', - InputOption::VALUE_NONE, - 'Will retry the location of visits that were located with a not-found location, in case it was due to ' - . 'a temporal issue.', - ) - ->addOption( - 'all', - 'a', - InputOption::VALUE_NONE, - 'When provided together with --retry, will locate all existing visits, regardless the fact that they ' - . 'have already been located.', - ); - } - - protected function initialize(InputInterface $input, OutputInterface $output): void - { - $this->io = new SymfonyStyle($input, $output); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $retry = $input->getOption('retry'); - $all = $input->getOption('all'); + public function __invoke( + SymfonyStyle $io, + #[Option( + 'Will retry the location of visits that were located with a not-found location, in case it was due to ' + . 'a temporal issue.', + shortcut: 'r', + )] + bool $retry = false, + #[Option( + 'When provided together with --retry, will locate all existing visits, regardless the fact that they ' + . 'have already been located.', + shortcut: 'a', + )] + bool $all = false, + ): int { + $this->io = $io; if ($all && !$retry) { - $this->io->writeln( + $io->writeln( 'The --all flag has no effect on its own. You have to provide it ' . 'together with --retry.', ); @@ -84,6 +71,13 @@ class LocateVisitsCommand extends Command implements VisitGeolocationHelperInter if ($all && $retry && ! $this->warnAndVerifyContinue()) { throw new RuntimeException('Execution aborted'); } + + return CommandUtils::executeWithLock( + $this->locker, + LockConfig::nonBlocking(self::NAME), + $io, + fn () => $this->locateVisits($retry, $all), + ); } private function warnAndVerifyContinue(): bool @@ -98,21 +92,8 @@ class LocateVisitsCommand extends Command implements VisitGeolocationHelperInter return $this->io->confirm('Do you want to proceed?', false); } - protected function execute(InputInterface $input, OutputInterface $output): int + private function locateVisits(bool $retry, bool $all): int { - return CommandUtils::executeWithLock( - $this->locker, - LockConfig::nonBlocking(self::NAME), - new SymfonyStyle($input, $output), - fn () => $this->locateVisits($input), - ); - } - - private function locateVisits(InputInterface $input): int - { - $retry = $input->getOption('retry'); - $all = $retry && $input->getOption('all'); - try { $this->checkDbUpdate(); From 83e373e96af6be0b1fa426b4df2051c16eba31d7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 16 Dec 2025 09:07:17 +0100 Subject: [PATCH 43/71] Decouple database commands from AbstractDatabaseCommand --- module/CLI/config/dependencies.config.php | 11 ++--- .../Command/Db/AbstractDatabaseCommand.php | 18 +-------- .../src/Command/Db/CreateDatabaseCommand.php | 12 +++--- .../src/Command/Db/MigrateDatabaseCommand.php | 15 +++++-- module/CLI/src/Util/PhpProcessRunner.php | 26 ++++++++++++ .../Command/Db/CreateDatabaseCommandTest.php | 11 ++--- .../Command/Db/MigrateDatabaseCommandTest.php | 11 ++--- module/CLI/test/Util/PhpProcessRunnerTest.php | 40 +++++++++++++++++++ 8 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 module/CLI/src/Util/PhpProcessRunner.php create mode 100644 module/CLI/test/Util/PhpProcessRunnerTest.php diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index b4bf15d2..a9bb47f2 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -32,6 +32,7 @@ return [ RedirectRule\RedirectRuleHandler::class => InvokableFactory::class, Util\ProcessRunner::class => ConfigAbstractFactory::class, + Util\PhpProcessRunner::class => ConfigAbstractFactory::class, ApiKey\RoleResolver::class => ConfigAbstractFactory::class, @@ -79,6 +80,7 @@ return [ ConfigAbstractFactory::class => [ Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class], + Util\PhpProcessRunner::class => [Util\ProcessRunner::class, PhpExecutableFinder::class], ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class], Command\ShortUrl\CreateShortUrlCommand::class => [ @@ -136,16 +138,11 @@ return [ Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, - Util\ProcessRunner::class, - PhpExecutableFinder::class, + Util\PhpProcessRunner::class, 'em', NoDbNameConnectionFactory::SERVICE_NAME, ], - Command\Db\MigrateDatabaseCommand::class => [ - LockFactory::class, - Util\ProcessRunner::class, - PhpExecutableFinder::class, - ], + Command\Db\MigrateDatabaseCommand::class => [LockFactory::class, Util\PhpProcessRunner::class], ], ]; diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index a38abc72..b7bfbc9d 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -6,31 +6,17 @@ namespace Shlinkio\Shlink\CLI\Command\Db; use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; use Shlinkio\Shlink\CLI\Command\Util\LockConfig; -use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Process\PhpExecutableFinder; abstract class AbstractDatabaseCommand extends Command { - private string $phpBinary; - - public function __construct( - private readonly LockFactory $locker, - private readonly ProcessRunnerInterface $processRunner, - PhpExecutableFinder $phpFinder, - ) { - parent::__construct(); - $this->phpBinary = $phpFinder->find(false) ?: 'php'; - } - - protected function runPhpCommand(OutputInterface $output, array $command): void + public function __construct(private readonly LockFactory $locker) { - $command = [$this->phpBinary, ...$command, '--no-interaction']; - $this->processRunner->run($output, $command); + parent::__construct(); } final protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 6dc11dfc..876128ad 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -12,7 +12,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Process\PhpExecutableFinder; use Throwable; use function array_map; @@ -24,18 +23,17 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand private readonly Connection $regularConn; public const string NAME = 'db:create'; - public const string DOCTRINE_SCRIPT = 'bin/doctrine'; - public const string DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create'; + public const string SCRIPT = 'bin/doctrine'; + public const string COMMAND = 'orm:schema-tool:create'; public function __construct( LockFactory $locker, - ProcessRunnerInterface $processRunner, - PhpExecutableFinder $phpFinder, + private readonly ProcessRunnerInterface $processRunner, private readonly EntityManagerInterface $em, private readonly Connection $noDbNameConn, ) { $this->regularConn = $this->em->getConnection(); - parent::__construct($locker, $processRunner, $phpFinder); + parent::__construct($locker); } protected function configure(): void @@ -59,7 +57,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand // Create database $io->writeln('Creating database tables...'); - $this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]); + $this->processRunner->run($output, [self::SCRIPT, self::COMMAND, '--no-interaction']); $io->success('Database properly created!'); return self::SUCCESS; diff --git a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php index 669b190b..f4a8c2ac 100644 --- a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php @@ -4,15 +4,24 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; +use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Lock\LockFactory; class MigrateDatabaseCommand extends AbstractDatabaseCommand { public const string NAME = 'db:migrate'; - public const string DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php'; - public const string DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate'; + public const string SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php'; + public const string COMMAND = 'migrations:migrate'; + + public function __construct( + LockFactory $locker, + private readonly ProcessRunnerInterface $processRunner, + ) { + parent::__construct($locker); + } protected function configure(): void { @@ -27,7 +36,7 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand $io = new SymfonyStyle($input, $output); $io->writeln('Migrating database...'); - $this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]); + $this->processRunner->run($output, [self::SCRIPT, self::COMMAND, '--no-interaction']); $io->success('Database properly migrated!'); return self::SUCCESS; diff --git a/module/CLI/src/Util/PhpProcessRunner.php b/module/CLI/src/Util/PhpProcessRunner.php new file mode 100644 index 00000000..47f8161c --- /dev/null +++ b/module/CLI/src/Util/PhpProcessRunner.php @@ -0,0 +1,26 @@ +phpBinary = $phpFinder->find(includeArgs: false) ?: 'php'; + } + + public function run(OutputInterface $output, array $cmd): void + { + $this->wrappedProcessRunner->run($output, [$this->phpBinary, ...$cmd]); + } +} diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 6e680c7a..4ea9dc32 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -25,7 +25,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\SharedLockInterface; -use Symfony\Component\Process\PhpExecutableFinder; class CreateDatabaseCommandTest extends TestCase { @@ -44,9 +43,6 @@ class CreateDatabaseCommandTest extends TestCase $lock->method('acquire')->willReturn(true); $locker->method('createLock')->willReturn($lock); - $phpExecutableFinder = $this->createStub(PhpExecutableFinder::class); - $phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php'); - $this->processHelper = $this->createMock(ProcessRunnerInterface::class); $this->schemaManager = $this->createMock(AbstractSchemaManager::class); @@ -63,7 +59,7 @@ class CreateDatabaseCommandTest extends TestCase $noDbNameConn = $this->createStub(Connection::class); $noDbNameConn->method('createSchemaManager')->willReturn($this->schemaManager); - $command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn); + $command = new CreateDatabaseCommand($locker, $this->processHelper, $em, $noDbNameConn); $this->commandTester = CliTestUtils::testerForCommand($command); } @@ -112,9 +108,8 @@ class CreateDatabaseCommandTest extends TestCase $this->schemaManager->expects($this->never())->method('createDatabase'); $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn($tables); $this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [ - '/usr/local/bin/php', - CreateDatabaseCommand::DOCTRINE_SCRIPT, - CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND, + CreateDatabaseCommand::SCRIPT, + CreateDatabaseCommand::COMMAND, '--no-interaction', ]); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index f3312803..b2987c6e 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -14,7 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\SharedLockInterface; -use Symfony\Component\Process\PhpExecutableFinder; class MigrateDatabaseCommandTest extends TestCase { @@ -28,12 +27,9 @@ class MigrateDatabaseCommandTest extends TestCase $lock->method('acquire')->willReturn(true); $locker->method('createLock')->willReturn($lock); - $phpExecutableFinder = $this->createStub(PhpExecutableFinder::class); - $phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php'); - $this->processHelper = $this->createMock(ProcessRunnerInterface::class); - $command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder); + $command = new MigrateDatabaseCommand($locker, $this->processHelper); $this->commandTester = CliTestUtils::testerForCommand($command); } @@ -41,9 +37,8 @@ class MigrateDatabaseCommandTest extends TestCase public function migrationsCommandIsRunWithProperVerbosity(): void { $this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [ - '/usr/local/bin/php', - MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT, - MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND, + MigrateDatabaseCommand::SCRIPT, + MigrateDatabaseCommand::COMMAND, '--no-interaction', ]); diff --git a/module/CLI/test/Util/PhpProcessRunnerTest.php b/module/CLI/test/Util/PhpProcessRunnerTest.php new file mode 100644 index 00000000..fb3293c4 --- /dev/null +++ b/module/CLI/test/Util/PhpProcessRunnerTest.php @@ -0,0 +1,40 @@ +wrapped = $this->createMock(ProcessRunnerInterface::class); + $this->executableFinder = $this->createMock(PhpExecutableFinder::class); + } + + #[Test] + #[TestWith([false, 'php'])] + #[TestWith(['/usr/local/bin/php', '/usr/local/bin/php'])] + public function commandsArePrefixedWithPhp(string|false $resolvedExecutable, string $expectedExecutable): void + { + $output = $this->createStub(OutputInterface::class); + $command = ['foo', 'bar', 'baz']; + + $this->wrapped->expects($this->once())->method('run')->with($output, [$expectedExecutable, ...$command]); + $this->executableFinder->expects($this->once())->method('find')->with(false)->willReturn($resolvedExecutable); + + new PhpProcessRunner($this->wrapped, $this->executableFinder)->run($output, $command); + } +} From 49daf9fbb661cd1893a0c5308cd79bdbdae806ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 16 Dec 2025 09:11:53 +0100 Subject: [PATCH 44/71] Remove AbstractDatabaseCommand --- .../Command/Db/AbstractDatabaseCommand.php | 33 ------------------- .../src/Command/Db/CreateDatabaseCommand.php | 22 ++++++++++--- .../src/Command/Db/MigrateDatabaseCommand.php | 22 ++++++++++--- 3 files changed, 34 insertions(+), 43 deletions(-) delete mode 100644 module/CLI/src/Command/Db/AbstractDatabaseCommand.php diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php deleted file mode 100644 index b7bfbc9d..00000000 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -locker, - LockConfig::blocking($this->getName() ?? static::class), - new SymfonyStyle($input, $output), - fn () => $this->lockedExecute($input, $output), - ); - } - - abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int; -} diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 876128ad..51503ec1 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -7,7 +7,10 @@ namespace Shlinkio\Shlink\CLI\Command\Db; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -18,7 +21,7 @@ use function array_map; use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function Shlinkio\Shlink\Core\ArrayUtils\some; -class CreateDatabaseCommand extends AbstractDatabaseCommand +class CreateDatabaseCommand extends Command { private readonly Connection $regularConn; @@ -27,13 +30,13 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand public const string COMMAND = 'orm:schema-tool:create'; public function __construct( - LockFactory $locker, + private readonly LockFactory $locker, private readonly ProcessRunnerInterface $processRunner, private readonly EntityManagerInterface $em, private readonly Connection $noDbNameConn, ) { $this->regularConn = $this->em->getConnection(); - parent::__construct($locker); + parent::__construct(); } protected function configure(): void @@ -46,10 +49,19 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand ); } - protected function lockedExecute(InputInterface $input, OutputInterface $output): int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + return CommandUtils::executeWithLock( + $this->locker, + LockConfig::blocking(self::NAME), + $io, + fn () => $this->executeCommand($io), + ); + } + private function executeCommand(SymfonyStyle $io): int + { if ($this->databaseTablesExist()) { $io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.'); return self::SUCCESS; @@ -57,7 +69,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand // Create database $io->writeln('Creating database tables...'); - $this->processRunner->run($output, [self::SCRIPT, self::COMMAND, '--no-interaction']); + $this->processRunner->run($io, [self::SCRIPT, self::COMMAND, '--no-interaction']); $io->success('Database properly created!'); return self::SUCCESS; diff --git a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php index f4a8c2ac..7dba9ae4 100644 --- a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php @@ -4,23 +4,26 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; -class MigrateDatabaseCommand extends AbstractDatabaseCommand +class MigrateDatabaseCommand extends Command { public const string NAME = 'db:migrate'; public const string SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php'; public const string COMMAND = 'migrations:migrate'; public function __construct( - LockFactory $locker, + private readonly LockFactory $locker, private readonly ProcessRunnerInterface $processRunner, ) { - parent::__construct($locker); + parent::__construct(); } protected function configure(): void @@ -31,12 +34,21 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand ->setDescription('Runs database migrations, which will ensure the shlink database is up to date.'); } - protected function lockedExecute(InputInterface $input, OutputInterface $output): int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + return CommandUtils::executeWithLock( + $this->locker, + LockConfig::blocking(self::NAME), + $io, + fn () => $this->executeCommand($io), + ); + } + private function executeCommand(SymfonyStyle $io): int + { $io->writeln('Migrating database...'); - $this->processRunner->run($output, [self::SCRIPT, self::COMMAND, '--no-interaction']); + $this->processRunner->run($io, [self::SCRIPT, self::COMMAND, '--no-interaction']); $io->success('Database properly migrated!'); return self::SUCCESS; From 5b80ee73bbae660e5eecae71f0113c9118b0330c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 16 Dec 2025 09:14:39 +0100 Subject: [PATCH 45/71] Convert database console commands into invokable commands --- .../src/Command/Db/CreateDatabaseCommand.php | 21 +++++++------------ .../src/Command/Db/MigrateDatabaseCommand.php | 19 +++++++---------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 51503ec1..9e6842eb 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -10,9 +10,8 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; use Throwable; @@ -21,6 +20,11 @@ use function array_map; use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function Shlinkio\Shlink\Core\ArrayUtils\some; +#[AsCommand( + name: CreateDatabaseCommand::NAME, + description: 'Creates the database needed for shlink to work. It will do nothing if the database already exists', + hidden: true, +)] class CreateDatabaseCommand extends Command { private readonly Connection $regularConn; @@ -39,19 +43,8 @@ class CreateDatabaseCommand extends Command parent::__construct(); } - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this - ->setName(self::NAME) - ->setHidden() - ->setDescription( - 'Creates the database needed for shlink to work. It will do nothing if the database already exists', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); return CommandUtils::executeWithLock( $this->locker, LockConfig::blocking(self::NAME), diff --git a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php index 7dba9ae4..ae520446 100644 --- a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php @@ -7,12 +7,16 @@ namespace Shlinkio\Shlink\CLI\Command\Db; use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; +#[AsCommand( + name: MigrateDatabaseCommand::NAME, + description: 'Runs database migrations, which will ensure the shlink database is up to date', + hidden: true, +)] class MigrateDatabaseCommand extends Command { public const string NAME = 'db:migrate'; @@ -26,17 +30,8 @@ class MigrateDatabaseCommand extends Command parent::__construct(); } - protected function configure(): void + public function __invoke(SymfonyStyle $io): int { - $this - ->setName(self::NAME) - ->setHidden() - ->setDescription('Runs database migrations, which will ensure the shlink database is up to date.'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); return CommandUtils::executeWithLock( $this->locker, LockConfig::blocking(self::NAME), From 66d35968f49422d8d0fb0b9185631fb740330c07 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:22:56 +0100 Subject: [PATCH 46/71] Convert GetNonOrphanVisitsCommand to invokable command --- composer.json | 4 +- .../Visit/AbstractVisitsListCommand.php | 37 +---------- .../Visit/DownloadGeoLiteDbCommand.php | 2 +- .../Visit/GetNonOrphanVisitsCommand.php | 61 +++++++++---------- .../src/Command/Visit/VisitsCommandUtils.php | 48 +++++++++++++++ module/CLI/src/Input/VisitsDateRangeInput.php | 28 +++++++++ 6 files changed, 110 insertions(+), 70 deletions(-) create mode 100644 module/CLI/src/Command/Visit/VisitsCommandUtils.php create mode 100644 module/CLI/src/Input/VisitsDateRangeInput.php diff --git a/composer.json b/composer.json index eeda898b..8d82756f 100644 --- a/composer.json +++ b/composer.json @@ -44,8 +44,8 @@ "shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.0", "shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0", "shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0", - "shlinkio/shlink-importer": "dev-main#4498f0a as 5.7.0", - "shlinkio/shlink-installer": "dev-develop#40e08cb as 10.0.0", + "shlinkio/shlink-importer": "dev-main#af03f6b as 5.7.0", + "shlinkio/shlink-installer": "dev-develop#a225b16 as 10.0.0", "shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0", "shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0", "spiral/roadrunner": "^2025.1", diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index 5916fc52..0bce0feb 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -15,11 +15,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function array_keys; -use function array_map; use function Shlinkio\Shlink\Common\buildDateRange; -use function Shlinkio\Shlink\Core\ArrayUtils\select_keys; -use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly; abstract class AbstractVisitsListCommand extends Command { @@ -38,44 +34,13 @@ abstract class AbstractVisitsListCommand extends Command $startDate = $this->startDateOption->get($input, $output); $endDate = $this->endDateOption->get($input, $output); $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate)); - [$rows, $headers] = $this->resolveRowsAndHeaders($paginator); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); ShlinkTable::default($output)->render($headers, $rows); return self::SUCCESS; } - /** - * @param Paginator $paginator - */ - private function resolveRowsAndHeaders(Paginator $paginator): array - { - $extraKeys = []; - $rows = array_map(function (Visit $visit) use (&$extraKeys) { - $extraFields = $this->mapExtraFields($visit); - $extraKeys = array_keys($extraFields); - - $rowData = [ - 'referer' => $visit->referer, - 'date' => $visit->date->toAtomString(), - 'userAgent' => $visit->userAgent, - 'potentialBot' => $visit->potentialBot, - 'country' => $visit->getVisitLocation()->countryName ?? 'Unknown', - 'city' => $visit->getVisitLocation()->cityName ?? 'Unknown', - ...$extraFields, - ]; - - // Filter out unknown keys - return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); - }, [...$paginator->getCurrentPageResults()]); - $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys); - - return [ - $rows, - ['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra], - ]; - } - /** * @return Paginator */ diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index f76a4dbc..e3e98fc6 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -17,7 +17,7 @@ use function sprintf; #[AsCommand( DownloadGeoLiteDbCommand::NAME, - 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so.', + 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so', )] class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface { diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 1b40d55e..6291d6db 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -4,57 +4,56 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Input\DomainOption; -use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; -use function sprintf; - -class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand +#[AsCommand(GetNonOrphanVisitsCommand::NAME, 'Returns the list of non-orphan visits')] +class GetNonOrphanVisitsCommand extends Command { public const string NAME = 'visit:non-orphan'; - private readonly DomainOption $domainOption; - public function __construct( - VisitsStatsHelperInterface $visitsHelper, + private readonly VisitsStatsHelperInterface $visitsHelper, private readonly ShortUrlStringifierInterface $shortUrlStringifier, ) { - parent::__construct($visitsHelper); - $this->domainOption = new DomainOption($this, sprintf( - 'Return visits that belong to this domain only. Use %s keyword for visits in default domain', - Domain::DEFAULT_AUTHORITY, - )); + parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Returns the list of non-orphan visits.'); - } - - /** - * @return Paginator - */ - protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator - { - return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams( - dateRange: $dateRange, - domain: $this->domainOption->get($input), + public function __invoke( + SymfonyStyle $io, + #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[Option( + 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits ' + . 'in default domain', + shortcut: 'd', + )] + string|null $domain = null, + ): int { + $paginator = $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams( + dateRange: $dateRangeInput->toDateRange(), + domain: $domain, )); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); + + ShlinkTable::default($io)->render($headers, $rows); + + return self::SUCCESS; } /** * @return array */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php new file mode 100644 index 00000000..55f425c7 --- /dev/null +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -0,0 +1,48 @@ + $paginator + * @param callable(Visit $visits): array $mapExtraFields + */ + public static function resolveRowsAndHeaders(Paginator $paginator, callable $mapExtraFields): array + { + $extraKeys = []; + $rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) { + $extraFields = $mapExtraFields($visit); + $extraKeys = array_keys($extraFields); + + $rowData = [ + 'referer' => $visit->referer, + 'date' => $visit->date->toAtomString(), + 'userAgent' => $visit->userAgent, + 'potentialBot' => $visit->potentialBot, + 'country' => $visit->getVisitLocation()->countryName ?? 'Unknown', + 'city' => $visit->getVisitLocation()->cityName ?? 'Unknown', + ...$extraFields, + ]; + + // Filter out unknown keys + return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); + }, [...$paginator->getCurrentPageResults()]); + $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys); + + return [ + $rows, + ['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra], + ]; + } +} diff --git a/module/CLI/src/Input/VisitsDateRangeInput.php b/module/CLI/src/Input/VisitsDateRangeInput.php new file mode 100644 index 00000000..189fa0b7 --- /dev/null +++ b/module/CLI/src/Input/VisitsDateRangeInput.php @@ -0,0 +1,28 @@ +startDate), + endDate: normalizeOptionalDate($this->endDate), + ); + } +} From aecc36a46359d4bb17e69c9e45be459f96de0d12 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:27:39 +0100 Subject: [PATCH 47/71] Convert GetOrphanVisitsCommand into invokable command --- .../Command/Visit/GetOrphanVisitsCommand.php | 70 ++++++++----------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 0804215a..5640361f 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -4,64 +4,56 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Input\DomainOption; -use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; -use function Shlinkio\Shlink\Core\enumToString; -use function sprintf; - -class GetOrphanVisitsCommand extends AbstractVisitsListCommand +#[AsCommand(GetOrphanVisitsCommand::NAME, 'Returns the list of orphan visits')] +class GetOrphanVisitsCommand extends Command { public const string NAME = 'visit:orphan'; - private readonly DomainOption $domainOption; - - public function __construct(VisitsStatsHelperInterface $visitsHelper) + public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) { - parent::__construct($visitsHelper); - $this->domainOption = new DomainOption($this, sprintf( - 'Return visits that belong to this domain only. Use %s keyword for visits in default domain', - Domain::DEFAULT_AUTHORITY, - )); + parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Returns the list of orphan visits.') - ->addOption('type', 't', InputOption::VALUE_REQUIRED, sprintf( - 'Return visits only with this type. One of %s', - enumToString(OrphanVisitType::class), - )); - } - - /** - * @return Paginator - */ - protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator - { - $rawType = $input->getOption('type'); - $type = $rawType !== null ? OrphanVisitType::from($rawType) : null; - return $this->visitsHelper->orphanVisits(new OrphanVisitsParams( - dateRange: $dateRange, - domain: $this->domainOption->get($input), + public function __invoke( + SymfonyStyle $io, + #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[Option( + 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits ' + . 'in default domain', + shortcut: 'd', + )] + string|null $domain = null, + #[Option('Return visits only with this type', shortcut: 't')] OrphanVisitType|null $type = null, + ): int { + $paginator = $this->visitsHelper->orphanVisits(new OrphanVisitsParams( + dateRange: $dateRangeInput->toDateRange(), + domain: $domain, type: $type, )); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); + + ShlinkTable::default($io)->render($headers, $rows); + + return self::SUCCESS; } /** * @return array */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { return ['type' => $visit->type->value]; } From ce7f334326d3d262de80e5a99e82b389b2a83416 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:32:21 +0100 Subject: [PATCH 48/71] Convert GetTagVisitsCommand into invokable command --- .../src/Command/Tag/GetTagVisitsCommand.php | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index bac12ac2..7db1b7e1 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -4,61 +4,60 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; -use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand; -use Shlinkio\Shlink\CLI\Input\DomainOption; -use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; +use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; +use Symfony\Component\Console\Attribute\MapInput; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; -use function sprintf; - -class GetTagVisitsCommand extends AbstractVisitsListCommand +#[AsCommand(GetTagVisitsCommand::NAME, 'Returns the list of visits for provided tag')] +class GetTagVisitsCommand extends Command { public const string NAME = 'tag:visits'; - private readonly DomainOption $domainOption; - public function __construct( - VisitsStatsHelperInterface $visitsHelper, + private readonly VisitsStatsHelperInterface $visitsHelper, private readonly ShortUrlStringifierInterface $shortUrlStringifier, ) { - parent::__construct($visitsHelper); - $this->domainOption = new DomainOption($this, sprintf( - 'Return visits that belong to this domain only. Use %s keyword for visits in default domain', - Domain::DEFAULT_AUTHORITY, - )); + parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Returns the list of visits for provided tag.') - ->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.'); - } - - /** - * @return Paginator - */ - protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator - { - $tag = $input->getArgument('tag'); - return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams( - dateRange: $dateRange, - domain: $this->domainOption->get($input), + public function __invoke( + SymfonyStyle $io, + #[Argument('The tag which visits we want to get'), Ask('For what tag do you want to get visits')] string $tag, + #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[Option( + 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits ' + . 'in default domain', + shortcut: 'd', + )] + string|null $domain = null, + ): int { + $paginator = $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams( + dateRange: $dateRangeInput->toDateRange(), + domain: $domain, )); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); + + ShlinkTable::default($io)->render($headers, $rows); + + return self::SUCCESS; } /** * @return array */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; From e265e55917200089eb5c19fbf7a61df087dae713 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:35:27 +0100 Subject: [PATCH 49/71] Convert GetDomainVisitsCommand into invokable command --- .../Command/Domain/GetDomainVisitsCommand.php | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index 2891c44f..c2b8d859 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -4,42 +4,44 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Domain; -use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand; -use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; +use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; +use Symfony\Component\Console\Attribute\MapInput; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; -class GetDomainVisitsCommand extends AbstractVisitsListCommand +#[AsCommand(GetDomainVisitsCommand::NAME, 'Returns the list of visits for provided domain')] +class GetDomainVisitsCommand extends Command { public const string NAME = 'domain:visits'; public function __construct( - VisitsStatsHelperInterface $visitsHelper, + private readonly VisitsStatsHelperInterface $visitsHelper, private readonly ShortUrlStringifierInterface $shortUrlStringifier, ) { - parent::__construct($visitsHelper); + parent::__construct(); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('Returns the list of visits for provided domain.') - ->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.'); - } + public function __invoke( + SymfonyStyle $io, + #[Argument('The domain which visits we want to get'), Ask('For what domain do you want to get visits?')] + string $domain, + #[MapInput] VisitsDateRangeInput $dateRangeInput, + ): int { + $paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRangeInput->toDateRange())); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - /** - * @return Paginator - */ - protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator - { - $domain = $input->getArgument('domain'); - return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange)); + ShlinkTable::default($io)->render($headers, $rows); + + return self::SUCCESS; } /** From c6b83a64379189d642acb25038e3a7bf92179e68 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:43:14 +0100 Subject: [PATCH 50/71] Convert GetShortUrlVisitsCommand into invokable command --- .../ShortUrl/GetShortUrlVisitsCommand.php | 68 ++++++++----------- .../Visit/AbstractVisitsListCommand.php | 53 --------------- module/CLI/src/Input/DateOption.php | 48 ------------- module/CLI/src/Input/DomainOption.php | 29 -------- module/CLI/src/Input/EndDateOption.php | 30 -------- .../CLI/src/Input/ShortUrlIdentifierInput.php | 34 ---------- module/CLI/src/Input/StartDateOption.php | 30 -------- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 29 +------- 8 files changed, 33 insertions(+), 288 deletions(-) delete mode 100644 module/CLI/src/Command/Visit/AbstractVisitsListCommand.php delete mode 100644 module/CLI/src/Input/DateOption.php delete mode 100644 module/CLI/src/Input/DomainOption.php delete mode 100644 module/CLI/src/Input/EndDateOption.php delete mode 100644 module/CLI/src/Input/ShortUrlIdentifierInput.php delete mode 100644 module/CLI/src/Input/StartDateOption.php diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 8507b9ca..83347319 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -4,61 +4,53 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand; -use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; -use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; +use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Util\ShlinkTable; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; +use Symfony\Component\Console\Attribute\MapInput; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; -class GetShortUrlVisitsCommand extends AbstractVisitsListCommand +#[AsCommand(GetShortUrlVisitsCommand::NAME, 'Returns the detailed visits information for provided short code')] +class GetShortUrlVisitsCommand extends Command { public const string NAME = 'short-url:visits'; - private ShortUrlIdentifierInput $shortUrlIdentifierInput; - - protected function configure(): void + public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper) { - $this - ->setName(self::NAME) - ->setDescription('Returns the detailed visits information for provided short code'); - $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( - $this, - shortCodeDesc: 'The short code which visits we want to get.', - domainDesc: 'The domain for the short code.', - ); + parent::__construct(); } - protected function interact(InputInterface $input, OutputInterface $output): void - { - $shortCode = $this->shortUrlIdentifierInput->shortCode($input); - if (! empty($shortCode)) { - return; - } + public function __invoke( + SymfonyStyle $io, + #[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')] + string $shortCode, + #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[Option('The domain for the short code', shortcut: 'd')] + string|null $domain = null, + ): int { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); + $dateRange = $dateRangeInput->toDateRange(); + $paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - $io = new SymfonyStyle($input, $output); - $shortCode = $io->ask('A short code was not provided. Which short code do you want to use?'); - if (! empty($shortCode)) { - $input->setArgument('shortCode', $shortCode); - } - } + ShlinkTable::default($io)->render($headers, $rows); - /** - * @return Paginator - */ - protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator - { - $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); - return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); + return self::SUCCESS; } /** * @return array */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { return []; } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php deleted file mode 100644 index 0bce0feb..00000000 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ /dev/null @@ -1,53 +0,0 @@ -startDateOption = new StartDateOption($this, 'visits'); - $this->endDateOption = new EndDateOption($this, 'visits'); - } - - final protected function execute(InputInterface $input, OutputInterface $output): int - { - $startDate = $this->startDateOption->get($input, $output); - $endDate = $this->endDateOption->get($input, $output); - $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate)); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - - ShlinkTable::default($output)->render($headers, $rows); - - return self::SUCCESS; - } - - /** - * @return Paginator - */ - abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator; - - /** - * @return array - */ - abstract protected function mapExtraFields(Visit $visit): array; -} diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php deleted file mode 100644 index 05c9de94..00000000 --- a/module/CLI/src/Input/DateOption.php +++ /dev/null @@ -1,48 +0,0 @@ -addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); - } - - public function get(InputInterface $input, OutputInterface $output): Chronos|null - { - $value = $input->getOption($this->name); - if (empty($value) || ! is_string($value)) { - return null; - } - - try { - return normalizeOptionalDate($value); - } catch (Throwable $e) { - $output->writeln(sprintf( - '> Ignored provided "%s" since its value "%s" is not a valid date. <', - $this->name, - $value, - )); - - if ($output->isVeryVerbose()) { - $this->command->getApplication()?->renderThrowable($e, $output); - } - - return null; - } - } -} diff --git a/module/CLI/src/Input/DomainOption.php b/module/CLI/src/Input/DomainOption.php deleted file mode 100644 index e7a15f52..00000000 --- a/module/CLI/src/Input/DomainOption.php +++ /dev/null @@ -1,29 +0,0 @@ -addOption( - name: self::NAME, - shortcut: 'd', - mode: InputOption::VALUE_REQUIRED, - description: $description, - ); - } - - public function get(InputInterface $input): string|null - { - return $input->getOption(self::NAME); - } -} diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php deleted file mode 100644 index a38b9b32..00000000 --- a/module/CLI/src/Input/EndDateOption.php +++ /dev/null @@ -1,30 +0,0 @@ -dateOption = new DateOption($command, 'end-date', 'e', sprintf( - 'Allows to filter %s, returning only those newer than provided date.', - $descriptionHint, - )); - } - - public function get(InputInterface $input, OutputInterface $output): Chronos|null - { - return $this->dateOption->get($input, $output); - } -} diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php deleted file mode 100644 index 46ac79da..00000000 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ /dev/null @@ -1,34 +0,0 @@ -addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc) - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc); - } - - public function shortCode(InputInterface $input): string|null - { - return $input->getArgument('shortCode'); - } - - public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier - { - $shortCode = $input->getArgument('shortCode'); - $domain = $input->getOption('domain'); - - return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); - } -} diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php deleted file mode 100644 index 453b31a2..00000000 --- a/module/CLI/src/Input/StartDateOption.php +++ /dev/null @@ -1,30 +0,0 @@ -dateOption = new DateOption($command, 'start-date', 's', sprintf( - 'Allows to filter %s, returning only those older than provided date.', - $descriptionHint, - )); - } - - public function get(InputInterface $input, OutputInterface $output): Chronos|null - { - return $this->dateOption->get($input, $output); - } -} diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index e306b0bc..3fd53c48 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -24,7 +24,6 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function Shlinkio\Shlink\Common\buildDateRange; -use function sprintf; class GetShortUrlVisitsCommandTest extends TestCase { @@ -47,7 +46,7 @@ class GetShortUrlVisitsCommandTest extends TestCase new VisitsParams(DateRange::allTime()), )->willReturn(new Paginator(new ArrayAdapter([]))); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); } #[Test] @@ -75,34 +74,12 @@ class GetShortUrlVisitsCommandTest extends TestCase )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute([ - 'shortCode' => $shortCode, + 'short-code' => $shortCode, '--start-date' => $startDate, '--end-date' => $endDate, ]); } - #[Test] - public function providingInvalidDatesPrintsWarning(): void - { - $shortCode = 'abc123'; - $startDate = 'foo'; - $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( - ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - new VisitsParams(DateRange::allTime()), - )->willReturn(new Paginator(new ArrayAdapter([]))); - - $this->commandTester->execute([ - 'shortCode' => $shortCode, - '--start-date' => $startDate, - ]); - $output = $this->commandTester->getDisplay(); - - self::assertStringContainsString( - sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate), - $output, - ); - } - #[Test] public function outputIsProperlyGenerated(): void { @@ -115,7 +92,7 @@ class GetShortUrlVisitsCommandTest extends TestCase $this->anything(), )->willReturn(new Paginator(new ArrayAdapter([$visit]))); - $this->commandTester->execute(['shortCode' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertEquals( From d0ee6e549bc0f357f95ed5ffb283ad20aecb3d7f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:46:15 +0100 Subject: [PATCH 51/71] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4c36b8..beffec7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink. * [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0. +* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable. ### Deprecated * *Nothing* From a774778822bc8fa9868345f5abcf402ff07e65cc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:48:05 +0100 Subject: [PATCH 52/71] Remove unecessary method from GetShortUrlVisitsCommand --- .../src/Command/ShortUrl/GetShortUrlVisitsCommand.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 83347319..38d9e371 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -8,7 +8,6 @@ use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Symfony\Component\Console\Attribute\Argument; @@ -40,18 +39,10 @@ class GetShortUrlVisitsCommand extends Command $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); $dateRange = $dateRangeInput->toDateRange(); $paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, static fn () => []); ShlinkTable::default($io)->render($headers, $rows); return self::SUCCESS; } - - /** - * @return array - */ - private function mapExtraFields(Visit $visit): array - { - return []; - } } From dae52fedf4609fdb6ff2b0aa1bff19728a452b1b Mon Sep 17 00:00:00 2001 From: Andrei Vasilev Date: Wed, 7 May 2025 23:04:05 +0700 Subject: [PATCH 53/71] Support for redirects with a condition before date --- CHANGELOG.md | 4 +++- .../definitions/SetShortUrlRedirectRule.json | 3 ++- .../CLI/src/RedirectRule/RedirectRuleHandler.php | 3 +++ .../RedirectRule/RedirectRuleHandlerTest.php | 5 +++++ .../RedirectRule/Entity/RedirectCondition.php | 14 ++++++++++++++ .../RedirectRule/Model/RedirectConditionType.php | 1 + .../Entity/RedirectConditionTest.php | 16 ++++++++++++++++ 7 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beffec7c..9585bc34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based condition for the dynamic rules redirections system. + + * `before-date`: Allows to perform redirections based on an ISO 8601 date value, when the current date and time is earlier than the defined threshold. ### Changed * [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue. diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 15380faa..71fc47da 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -23,7 +23,8 @@ "valueless-query-param", "ip-address", "geolocation-country-code", - "geolocation-city-name" + "geolocation-city-name", + "before-date" ], "description": "The type of the condition, which will determine the logic used to match it" }, diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index baab9c9e..c1251b1d 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -122,6 +122,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface ), RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName( $this->askMandatory('City name to match?', $io), + ), + RedirectConditionType::BEFORE_DATE => RedirectCondition::forBeforeDate( + $this->askMandatory('Date to match? (ISO 8601)', $io), ) }; diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index 76e48dc8..c93b5b71 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -122,6 +122,7 @@ class RedirectRuleHandlerTest extends TestCase 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', 'Country code to match?' => 'FR', 'City name to match?' => 'Los angeles', + 'Date to match? (ISO 8601)' => '2016-05-01T20:34:16+02:00', default => '', }, ); @@ -186,6 +187,10 @@ class RedirectRuleHandlerTest extends TestCase RedirectConditionType::GEOLOCATION_CITY_NAME, [RedirectCondition::forGeolocationCityName('Los angeles')], ]; + yield 'Before date' => [ + RedirectConditionType::BEFORE_DATE, + [RedirectCondition::forBeforeDate('2016-05-01T20:34:16+02:00')], + ]; } #[Test] diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 2413400d..167b723a 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; +use Cake\Chronos\Chronos; use JsonSerializable; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; @@ -75,6 +76,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::GEOLOCATION_CITY_NAME, $cityName); } + public static function forBeforeDate(string $date): self + { + return new self(RedirectConditionType::BEFORE_DATE, $date); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -100,6 +106,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::IP_ADDRESS => self::forIpAddress($cond->matchValue), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => self::forGeolocationCountryCode($cond->matchValue), RedirectConditionType::GEOLOCATION_CITY_NAME => self::forGeolocationCityName($cond->matchValue), + RedirectConditionType::BEFORE_DATE => self::forBeforeDate($cond->matchValue), }; } @@ -117,6 +124,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), RedirectConditionType::GEOLOCATION_CITY_NAME => $this->matchesGeolocationCityName($request), + RedirectConditionType::BEFORE_DATE => $this->matchesBeforeDate(), }; } @@ -200,6 +208,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return strcasecmp($geolocation->city, $this->matchValue) === 0; } + private function matchesBeforeDate(): bool + { + return Chronos::now()->lessThan(Chronos::parse($this->matchValue)); + } + public function jsonSerialize(): array { return [ @@ -230,6 +243,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), RedirectConditionType::GEOLOCATION_CITY_NAME => sprintf('city name is %s', $this->matchValue), + RedirectConditionType::BEFORE_DATE => sprintf('date before %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 0461d968..49f4536a 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -20,6 +20,7 @@ enum RedirectConditionType: string case IP_ADDRESS = 'ip-address'; case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; case GEOLOCATION_CITY_NAME = 'geolocation-city-name'; + case BEFORE_DATE = 'before-date'; /** * Tells if a value is valid for the condition type diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index cc544353..5edda6af 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -2,6 +2,7 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity; +use Cake\Chronos\Chronos; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -195,4 +196,19 @@ class RedirectConditionTest extends TestCase ); self::assertEquals($expectedType, $condition?->type); } + + #[Test, DataProvider('provideVisitsWithBeforeDateCondition')] + public function matchesBeforeDate(string $date, bool $expectedResult): void + { + $request = ServerRequestFactory::fromGlobals(); + $result = RedirectCondition::forBeforeDate($date)->matchesRequest($request); + + self::assertEquals($expectedResult, $result); + } + + public static function provideVisitsWithBeforeDateCondition(): iterable + { + yield 'date later than current' => [Chronos::now()->addHours(1)->toIso8601String(), true]; + yield 'date earlier than current' => [Chronos::now()->subHours(1)->toIso8601String(), false]; + } } From 54dc82cb902d22826d0753b6e086e6e1d13ae7f6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 18 Dec 2025 09:11:54 +0100 Subject: [PATCH 54/71] Type date in RedirectCondition::forBeforeDate as Chronos --- module/CLI/src/RedirectRule/RedirectRuleHandler.php | 5 +++-- module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php | 3 ++- module/Core/src/RedirectRule/Entity/RedirectCondition.php | 7 ++++--- .../test/RedirectRule/Entity/RedirectConditionTest.php | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index c1251b1d..689bbb20 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -24,6 +24,7 @@ use function max; use function min; use function Shlinkio\Shlink\Core\ArrayUtils\map; use function Shlinkio\Shlink\Core\enumValues; +use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; use function str_pad; use function strlen; @@ -124,8 +125,8 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface $this->askMandatory('City name to match?', $io), ), RedirectConditionType::BEFORE_DATE => RedirectCondition::forBeforeDate( - $this->askMandatory('Date to match? (ISO 8601)', $io), - ) + normalizeDate($this->askMandatory('Date to match? (ISO 8601)', $io)), + ), }; $continue = $io->confirm('Do you want to add another condition?'); diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index c93b5b71..60422949 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -19,6 +19,7 @@ use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Symfony\Component\Console\Style\StyleInterface; +use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; #[AllowMockObjectsWithoutExpectations] @@ -189,7 +190,7 @@ class RedirectRuleHandlerTest extends TestCase ]; yield 'Before date' => [ RedirectConditionType::BEFORE_DATE, - [RedirectCondition::forBeforeDate('2016-05-01T20:34:16+02:00')], + [RedirectCondition::forBeforeDate(normalizeDate('2016-05-01T20:34:16+02:00'))], ]; } diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 167b723a..45e41c1e 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -17,6 +17,7 @@ use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; +use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; use function sprintf; @@ -76,9 +77,9 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::GEOLOCATION_CITY_NAME, $cityName); } - public static function forBeforeDate(string $date): self + public static function forBeforeDate(Chronos $date): self { - return new self(RedirectConditionType::BEFORE_DATE, $date); + return new self(RedirectConditionType::BEFORE_DATE, $date->toAtomString()); } public static function fromRawData(array $rawData): self @@ -106,7 +107,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::IP_ADDRESS => self::forIpAddress($cond->matchValue), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => self::forGeolocationCountryCode($cond->matchValue), RedirectConditionType::GEOLOCATION_CITY_NAME => self::forGeolocationCityName($cond->matchValue), - RedirectConditionType::BEFORE_DATE => self::forBeforeDate($cond->matchValue), + RedirectConditionType::BEFORE_DATE => self::forBeforeDate(normalizeDate($cond->matchValue)), }; } diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 5edda6af..b35679d9 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -198,7 +198,7 @@ class RedirectConditionTest extends TestCase } #[Test, DataProvider('provideVisitsWithBeforeDateCondition')] - public function matchesBeforeDate(string $date, bool $expectedResult): void + public function matchesBeforeDate(Chronos $date, bool $expectedResult): void { $request = ServerRequestFactory::fromGlobals(); $result = RedirectCondition::forBeforeDate($date)->matchesRequest($request); @@ -208,7 +208,7 @@ class RedirectConditionTest extends TestCase public static function provideVisitsWithBeforeDateCondition(): iterable { - yield 'date later than current' => [Chronos::now()->addHours(1)->toIso8601String(), true]; - yield 'date earlier than current' => [Chronos::now()->subHours(1)->toIso8601String(), false]; + yield 'date later than current' => [Chronos::now()->addHours(1), true]; + yield 'date earlier than current' => [Chronos::now()->subHours(1), false]; } } From ca183d6e213192ce43fa152e4be12b3040692dd9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 18 Dec 2025 09:27:11 +0100 Subject: [PATCH 55/71] Some changes in before-date rule wording --- CHANGELOG.md | 2 +- module/CLI/src/RedirectRule/RedirectRuleHandler.php | 2 +- module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9585bc34..83f75b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Added * [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based condition for the dynamic rules redirections system. - * `before-date`: Allows to perform redirections based on an ISO 8601 date value, when the current date and time is earlier than the defined threshold. + * `before-date`: Allows to perform redirections based on an ISO-8601 date value, when the current date and time is earlier than the defined threshold. ### Changed * [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue. diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 689bbb20..0992670d 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -125,7 +125,7 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface $this->askMandatory('City name to match?', $io), ), RedirectConditionType::BEFORE_DATE => RedirectCondition::forBeforeDate( - normalizeDate($this->askMandatory('Date to match? (ISO 8601)', $io)), + normalizeDate($this->askMandatory('Date to match?', $io)), ), }; diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index 60422949..12af8d40 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -123,7 +123,7 @@ class RedirectRuleHandlerTest extends TestCase 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', 'Country code to match?' => 'FR', 'City name to match?' => 'Los angeles', - 'Date to match? (ISO 8601)' => '2016-05-01T20:34:16+02:00', + 'Date to match?' => '2016-05-01T20:34:16+02:00', default => '', }, ); From 9ae2dce26166d062157c42fbd0ee9c55fc6c9fa3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 18 Dec 2025 09:41:07 +0100 Subject: [PATCH 56/71] Support dynamic redirects based on an after-date condition --- CHANGELOG.md | 5 +++-- .../definitions/SetShortUrlRedirectRule.json | 3 ++- .../CLI/src/RedirectRule/RedirectRuleHandler.php | 3 +++ .../test/RedirectRule/RedirectRuleHandlerTest.php | 4 ++++ .../src/RedirectRule/Entity/RedirectCondition.php | 15 ++++++++++++++- .../RedirectRule/Model/RedirectConditionType.php | 1 + .../RedirectRule/Entity/RedirectConditionTest.php | 15 +++++++++++++++ 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f75b8b..f33116af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based condition for the dynamic rules redirections system. +* [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based conditions for the dynamic rules redirections system, that allow to perform redirections based on an ISO-8601 date value. - * `before-date`: Allows to perform redirections based on an ISO-8601 date value, when the current date and time is earlier than the defined threshold. + * `before-date`: matches when current date and time is earlier than the defined threshold. + * `after-date`: matches when current date and time is later than the defined threshold. ### Changed * [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue. diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 71fc47da..0cbe7b37 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -24,7 +24,8 @@ "ip-address", "geolocation-country-code", "geolocation-city-name", - "before-date" + "before-date", + "after-date" ], "description": "The type of the condition, which will determine the logic used to match it" }, diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 0992670d..635bb48f 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -127,6 +127,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface RedirectConditionType::BEFORE_DATE => RedirectCondition::forBeforeDate( normalizeDate($this->askMandatory('Date to match?', $io)), ), + RedirectConditionType::AFTER_DATE => RedirectCondition::forAfterDate( + normalizeDate($this->askMandatory('Date to match?', $io)), + ), }; $continue = $io->confirm('Do you want to add another condition?'); diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index 12af8d40..fce94954 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -192,6 +192,10 @@ class RedirectRuleHandlerTest extends TestCase RedirectConditionType::BEFORE_DATE, [RedirectCondition::forBeforeDate(normalizeDate('2016-05-01T20:34:16+02:00'))], ]; + yield 'After date' => [ + RedirectConditionType::AFTER_DATE, + [RedirectCondition::forAfterDate(normalizeDate('2016-05-01T20:34:16+02:00'))], + ]; } #[Test] diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 45e41c1e..2ed2a235 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -82,6 +82,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::BEFORE_DATE, $date->toAtomString()); } + public static function forAfterDate(Chronos $date): self + { + return new self(RedirectConditionType::AFTER_DATE, $date->toAtomString()); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -108,6 +113,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::GEOLOCATION_COUNTRY_CODE => self::forGeolocationCountryCode($cond->matchValue), RedirectConditionType::GEOLOCATION_CITY_NAME => self::forGeolocationCityName($cond->matchValue), RedirectConditionType::BEFORE_DATE => self::forBeforeDate(normalizeDate($cond->matchValue)), + RedirectConditionType::AFTER_DATE => self::forAfterDate(normalizeDate($cond->matchValue)), }; } @@ -126,6 +132,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), RedirectConditionType::GEOLOCATION_CITY_NAME => $this->matchesGeolocationCityName($request), RedirectConditionType::BEFORE_DATE => $this->matchesBeforeDate(), + RedirectConditionType::AFTER_DATE => $this->matchesAfterDate(), }; } @@ -214,6 +221,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return Chronos::now()->lessThan(Chronos::parse($this->matchValue)); } + private function matchesAfterDate(): bool + { + return Chronos::now()->greaterThan(Chronos::parse($this->matchValue)); + } + public function jsonSerialize(): array { return [ @@ -244,7 +256,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), RedirectConditionType::GEOLOCATION_CITY_NAME => sprintf('city name is %s', $this->matchValue), - RedirectConditionType::BEFORE_DATE => sprintf('date before %s', $this->matchValue), + RedirectConditionType::BEFORE_DATE => sprintf('date is before %s', $this->matchValue), + RedirectConditionType::AFTER_DATE => sprintf('date is after %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 49f4536a..70347106 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -21,6 +21,7 @@ enum RedirectConditionType: string case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; case GEOLOCATION_CITY_NAME = 'geolocation-city-name'; case BEFORE_DATE = 'before-date'; + case AFTER_DATE = 'after-date'; /** * Tells if a value is valid for the condition type diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index b35679d9..f6a528d9 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -211,4 +211,19 @@ class RedirectConditionTest extends TestCase yield 'date later than current' => [Chronos::now()->addHours(1), true]; yield 'date earlier than current' => [Chronos::now()->subHours(1), false]; } + + #[Test, DataProvider('provideVisitsWithAfterDateCondition')] + public function matchesAfterDate(Chronos $date, bool $expectedResult): void + { + $request = ServerRequestFactory::fromGlobals(); + $result = RedirectCondition::forAfterDate($date)->matchesRequest($request); + + self::assertEquals($expectedResult, $result); + } + + public static function provideVisitsWithAfterDateCondition(): iterable + { + yield 'date later than current' => [Chronos::now()->addHours(1), false]; + yield 'date earlier than current' => [Chronos::now()->subHours(1), true]; + } } From 0ad777b6fac88e04e1c5bc59de7dd61a5e97dc2e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 20 Dec 2025 13:22:44 +0100 Subject: [PATCH 57/71] Fix error when setting max results in a delete query --- composer.json | 6 +++--- .../ShortUrlRedirectRuleService.php | 12 +++--------- .../Persistence/ShortUrlsListFiltering.php | 4 +--- .../Repository/ExpiredShortUrlsRepository.php | 8 ++------ .../Core/src/ShortUrl/ShortUrlListService.php | 4 +--- module/Core/src/Tag/TagService.php | 16 ++++------------ module/Core/src/Visit/VisitsStatsHelper.php | 16 ++++------------ .../src/ApiKey/Repository/ApiKeyRepository.php | 18 ++++++------------ module/Rest/src/Service/ApiKeyService.php | 16 ++++------------ 9 files changed, 28 insertions(+), 72 deletions(-) diff --git a/composer.json b/composer.json index 8d82756f..3e35cc4f 100644 --- a/composer.json +++ b/composer.json @@ -19,10 +19,10 @@ "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.6", "cakephp/chronos": "^3.1", - "doctrine/dbal": "^4.3", + "doctrine/dbal": "^4.4", "doctrine/migrations": "^3.9", - "doctrine/orm": "^3.5", - "donatj/phpuseragentparser": "^1.10", + "doctrine/orm": "^3.6", + "donatj/phpuseragentparser": "^1.11", "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.1", "guzzlehttp/guzzle": "^7.9", diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index dac0dc61..2366dbaa 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -19,9 +19,7 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic { } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function rulesForShortUrl(ShortUrl $shortUrl): array { return $this->em->getRepository(ShortUrlRedirectRule::class)->findBy( @@ -30,9 +28,7 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic ); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array { $rules = []; @@ -54,9 +50,7 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic return $rules; } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void { $normalizedAndDetachedRules = map($rules, function (ShortUrlRedirectRule $rule, int|string|float $priority) { diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index f9350389..cbf300f6 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -12,9 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering { - /** - * @inheritDoc - */ + /** @inheritDoc */ public function __construct( public readonly int|null $limit = null, public readonly int|null $offset = null, diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php index 05944f29..62b37cd5 100644 --- a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php @@ -16,9 +16,7 @@ use function sprintf; /** @extends EntitySpecificationRepository */ class ExpiredShortUrlsRepository extends EntitySpecificationRepository implements ExpiredShortUrlsRepositoryInterface { - /** - * @inheritDoc - */ + /** @inheritDoc */ public function delete(ExpiredShortUrlsConditions $conditions): int { $qb = $this->getEntityManager()->createQueryBuilder(); @@ -27,9 +25,7 @@ class ExpiredShortUrlsRepository extends EntitySpecificationRepository implement return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->execute()); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function dryCount(ExpiredShortUrlsConditions $conditions): int { $qb = $this->getEntityManager()->createQueryBuilder(); diff --git a/module/Core/src/ShortUrl/ShortUrlListService.php b/module/Core/src/ShortUrl/ShortUrlListService.php index 2a1adb26..cc96e1ca 100644 --- a/module/Core/src/ShortUrl/ShortUrlListService.php +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -19,9 +19,7 @@ readonly class ShortUrlListService implements ShortUrlListServiceInterface ) { } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator { $defaultDomain = $this->urlShortenerOptions->defaultDomain; diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index a2cbcf2c..f10fbe99 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -24,17 +24,13 @@ readonly class TagService implements TagServiceInterface { } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator { return $this->createPaginator(new TagsPaginatorAdapter($this->repo, $params, $apiKey), $params); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator { return $this->createPaginator(new TagsInfoPaginatorAdapter($this->repo, $params, $apiKey), $params); @@ -54,9 +50,7 @@ readonly class TagService implements TagServiceInterface return $paginator; } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void { if (ApiKey::isShortUrlRestricted($apiKey)) { @@ -66,9 +60,7 @@ readonly class TagService implements TagServiceInterface $this->repo->deleteByName($tagNames); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag { if (ApiKey::isShortUrlRestricted($apiKey)) { diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index b9721f3c..d1f6c089 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -64,9 +64,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface ); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, @@ -87,9 +85,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface ); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function visitsForTag(string $tag, WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var TagRepository $tagRepo */ @@ -104,9 +100,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var DomainRepository $domainRepo */ @@ -121,9 +115,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var VisitRepository $repo */ diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index 8a6bc59d..1cfd39b3 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -15,9 +15,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; */ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface { - /** - * @inheritDoc - */ + /** @inheritDoc */ public function createInitialApiKey(string $apiKey): ApiKey|null { $em = $this->getEntityManager(); @@ -42,14 +40,13 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe }); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function nameExists(string $name): bool { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->select('a.id') - ->from(ApiKey::class, 'a'); + ->from(ApiKey::class, 'a') + ->setMaxResults(1); $this->queryBuilderByName($qb, $name); @@ -60,9 +57,7 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe return $query->getOneOrNullResult() !== null; } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function deleteByName(string $name): int { $qb = $this->getEntityManager()->createQueryBuilder(); @@ -79,7 +74,6 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe private function queryBuilderByName(QueryBuilder $qb, string $name): void { $qb->where($qb->expr()->eq('a.name', ':name')) - ->setParameter('name', $name) - ->setMaxResults(1); + ->setParameter('name', $name); } } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index d717126d..0da23f9e 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -21,9 +21,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface { } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function create(ApiKeyMeta $apiKeyMeta): ApiKey { return $this->em->wrapInTransaction(function () use ($apiKeyMeta) { @@ -68,9 +66,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return new ApiKeyCheckResult($apiKey); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function deleteByName(string $apiKeyName): void { $affectedResults = $this->repo->deleteByName($apiKeyName); @@ -79,9 +75,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface } } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function disableByName(string $apiKeyName): ApiKey { $apiKey = $this->repo->findOneBy(['name' => $apiKeyName]); @@ -109,9 +103,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $this->repo->findBy($conditions); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey { $apiKey = $this->repo->findOneBy(['name' => $apiKeyRenaming->oldName]); From ce9cbe2addbaa9e92080d2245cd717099b3f847f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 20 Dec 2025 13:16:47 +0100 Subject: [PATCH 58/71] Add support for redis connections via unix socket --- CHANGELOG.md | 2 ++ composer.json | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f33116af..caf7e118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * `before-date`: matches when current date and time is earlier than the defined threshold. * `after-date`: matches when current date and time is later than the defined threshold. +* [#2513](https://github.com/shlinkio/shlink/issues/2513) Add support for redis connections via unix socket (e.g. `REDIS_SERVERS=unix:/path/to/redis.sock`). + ### Changed * [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue. diff --git a/composer.json b/composer.json index 3e35cc4f..6b560969 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.2", - "shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.0", + "shlinkio/shlink-common": "dev-main#d4ae052 as 8.0.0", "shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0", "shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0", "shlinkio/shlink-importer": "dev-main#af03f6b as 5.7.0", @@ -68,7 +68,6 @@ "phpunit/php-code-coverage": "^12.0", "phpunit/phpcov": "^11.0", "phpunit/phpunit": "^12.0.10", - "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.5.0", "shlinkio/shlink-test-utils": "^4.4", "symfony/var-dumper": "^8.0", From 983a7f444ca30abc187dd5c9507ef9cb177fa621 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 20 Dec 2025 13:28:22 +0100 Subject: [PATCH 59/71] Document removal of redis database index as path --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf7e118..de5c2dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` deprecated options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead. * [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag. * [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead. +* Remove support to provide redis database index via URI path. Use `?database=3` query instead. ### Fixed * *Nothing* From 05b833b3994ada225fad9895423920529ecb3f31 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 22 Dec 2025 12:20:06 +0100 Subject: [PATCH 60/71] Install xdebug with pie in dev docker images --- data/infra/frankenphp.Dockerfile | 8 +++++--- data/infra/php.Dockerfile | 8 +++++--- data/infra/roadrunner.Dockerfile | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/data/infra/frankenphp.Dockerfile b/data/infra/frankenphp.Dockerfile index 3670ced3..0d814406 100644 --- a/data/infra/frankenphp.Dockerfile +++ b/data/infra/frankenphp.Dockerfile @@ -27,18 +27,20 @@ RUN docker-php-ext-install zip RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql +COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ + pie install xdebug/xdebug && \ apk del .phpize-deps RUN docker-php-ext-install bcmath -# Install xdebug and sqlsrv driver +# Install 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 && \ apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \ - docker-php-ext-enable pdo_sqlsrv xdebug && \ + pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \ + docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index fc71471c..16f9bcef 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -28,8 +28,10 @@ RUN docker-php-ext-install zip RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql +COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ + pie install xdebug/xdebug && \ apk del .phpize-deps RUN docker-php-ext-install bcmath @@ -43,13 +45,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \ && 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 +# Install 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 && \ apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \ - docker-php-ext-enable pdo_sqlsrv xdebug && \ + pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \ + docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 23adc1f7..361a71da 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -27,18 +27,20 @@ RUN docker-php-ext-install zip RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql +COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ + pie install xdebug/xdebug && \ apk del .phpize-deps RUN docker-php-ext-install bcmath -# Install xdebug and sqlsrv driver +# Install 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 && \ apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \ - docker-php-ext-enable pdo_sqlsrv xdebug && \ + pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \ + docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk From 18ac39ad9c104197fad5459fbed6e5a038f8abc5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 22 Dec 2025 12:27:32 +0100 Subject: [PATCH 61/71] Install APCU via pie in php dev docker image --- Dockerfile | 4 ++-- data/infra/php.Dockerfile | 11 +---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index d741d15c..b75ec607 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,12 @@ WORKDIR /etc/shlink # Install required PHP extensions RUN \ - # Temp install dev dependencies needed to compile the extensions + # Temp install dev dependencies needed to compile the extensions \ apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev linux-headers && \ docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip && \ apk add --no-cache sqlite-libs && \ docker-php-ext-install -j"$(nproc)" pdo_sqlite && \ - # Remove temp dev extensions, and install prod equivalents that are required at runtime + # Remove temp dev extensions, and install prod equivalents that are required at runtime \ apk del .dev-deps && \ apk add --no-cache postgresql icu libzip libpng diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 16f9bcef..162ca3ab 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -32,19 +32,10 @@ COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ pie install xdebug/xdebug && \ + pie install apcu/apcu && \ 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 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 && \ From 6eb94194a3f81bcd457dca6d1b5f4d05f568c256 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 22 Dec 2025 12:41:41 +0100 Subject: [PATCH 62/71] Install zip extension with pie --- data/infra/frankenphp.Dockerfile | 7 +++---- data/infra/php.Dockerfile | 7 +++---- data/infra/roadrunner.Dockerfile | 7 +++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/data/infra/frankenphp.Dockerfile b/data/infra/frankenphp.Dockerfile index 0d814406..ac27d1a2 100644 --- a/data/infra/frankenphp.Dockerfile +++ b/data/infra/frankenphp.Dockerfile @@ -21,16 +21,15 @@ RUN docker-php-ext-install pdo_sqlite RUN apk add --no-cache icu-dev RUN docker-php-ext-install intl -RUN apk add --no-cache libzip-dev zlib-dev -RUN docker-php-ext-install zip - RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie -RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ +RUN apk add --no-cache libzip-dev zlib-dev && \ + apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ pie install xdebug/xdebug && \ + pie install pecl/zip && \ apk del .phpize-deps RUN docker-php-ext-install bcmath diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 162ca3ab..652e1ace 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -22,16 +22,15 @@ RUN docker-php-ext-install pdo_sqlite RUN apk add --no-cache icu-dev RUN docker-php-ext-install intl -RUN apk add --no-cache libzip-dev zlib-dev -RUN docker-php-ext-install zip - RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie -RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ +RUN apk add --no-cache libzip-dev zlib-dev && \ + apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ pie install xdebug/xdebug && \ + pie install pecl/zip && \ pie install apcu/apcu && \ apk del .phpize-deps RUN docker-php-ext-install bcmath diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 361a71da..8ef7e969 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -21,16 +21,15 @@ RUN docker-php-ext-install pdo_sqlite RUN apk add --no-cache icu-dev RUN docker-php-ext-install intl -RUN apk add --no-cache libzip-dev zlib-dev -RUN docker-php-ext-install zip - RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie -RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ +RUN apk add --no-cache libzip-dev zlib-dev && \ + apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ docker-php-ext-install sockets && \ pie install xdebug/xdebug && \ + pie install pecl/zip && \ apk del .phpize-deps RUN docker-php-ext-install bcmath From faed7ae60b5b0f1c927af9369b7953d830e408d2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Dec 2025 11:59:37 +0100 Subject: [PATCH 63/71] Generalize VisitsDateRangeInput to VisitsListInput to add more common params --- module/CLI/src/Command/Domain/GetDomainVisitsCommand.php | 6 +++--- .../CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php | 8 ++++---- module/CLI/src/Command/Tag/GetTagVisitsCommand.php | 6 +++--- .../CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php | 6 +++--- module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php | 6 +++--- module/CLI/src/Command/Visit/VisitsCommandUtils.php | 6 ++++-- .../{VisitsDateRangeInput.php => VisitsListInput.php} | 4 ++-- 7 files changed, 22 insertions(+), 20 deletions(-) rename module/CLI/src/Input/{VisitsDateRangeInput.php => VisitsListInput.php} (90%) diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index c2b8d859..19790e3a 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; -use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -34,9 +34,9 @@ class GetDomainVisitsCommand extends Command SymfonyStyle $io, #[Argument('The domain which visits we want to get'), Ask('For what domain do you want to get visits?')] string $domain, - #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[MapInput] VisitsListInput $input, ): int { - $paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRangeInput->toDateRange())); + $paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange())); [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); ShlinkTable::default($io)->render($headers, $rows); diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 38d9e371..54f6c019 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; -use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; @@ -32,14 +32,14 @@ class GetShortUrlVisitsCommand extends Command SymfonyStyle $io, #[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')] string $shortCode, - #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[MapInput] VisitsListInput $input, #[Option('The domain for the short code', shortcut: 'd')] string|null $domain = null, ): int { $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); - $dateRange = $dateRangeInput->toDateRange(); + $dateRange = $input->dateRange(); $paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, static fn () => []); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator); ShlinkTable::default($io)->render($headers, $rows); diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index 7db1b7e1..426e253e 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; -use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; @@ -35,7 +35,7 @@ class GetTagVisitsCommand extends Command public function __invoke( SymfonyStyle $io, #[Argument('The tag which visits we want to get'), Ask('For what tag do you want to get visits')] string $tag, - #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[MapInput] VisitsListInput $input, #[Option( 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits ' . 'in default domain', @@ -44,7 +44,7 @@ class GetTagVisitsCommand extends Command string|null $domain = null, ): int { $paginator = $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams( - dateRange: $dateRangeInput->toDateRange(), + dateRange: $input->dateRange(), domain: $domain, )); [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 6291d6db..3620bbd3 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; @@ -31,7 +31,7 @@ class GetNonOrphanVisitsCommand extends Command public function __invoke( SymfonyStyle $io, - #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[MapInput] VisitsListInput $input, #[Option( 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits ' . 'in default domain', @@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommand extends Command string|null $domain = null, ): int { $paginator = $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams( - dateRange: $dateRangeInput->toDateRange(), + dateRange: $input->dateRange(), domain: $domain, )); [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 5640361f..d6c12c5c 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput; +use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -29,7 +29,7 @@ class GetOrphanVisitsCommand extends Command public function __invoke( SymfonyStyle $io, - #[MapInput] VisitsDateRangeInput $dateRangeInput, + #[MapInput] VisitsListInput $input, #[Option( 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits ' . 'in default domain', @@ -39,7 +39,7 @@ class GetOrphanVisitsCommand extends Command #[Option('Return visits only with this type', shortcut: 't')] OrphanVisitType|null $type = null, ): int { $paginator = $this->visitsHelper->orphanVisits(new OrphanVisitsParams( - dateRange: $dateRangeInput->toDateRange(), + dateRange: $input->dateRange(), domain: $domain, type: $type, )); diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php index 55f425c7..57ef2ec6 100644 --- a/module/CLI/src/Command/Visit/VisitsCommandUtils.php +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -16,11 +16,13 @@ class VisitsCommandUtils { /** * @param Paginator $paginator - * @param callable(Visit $visits): array $mapExtraFields + * @param null|callable(Visit $visits): array $mapExtraFields */ - public static function resolveRowsAndHeaders(Paginator $paginator, callable $mapExtraFields): array + public static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields = null): array { $extraKeys = []; + $mapExtraFields ??= static fn (Visit $_) => []; + $rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) { $extraFields = $mapExtraFields($visit); $extraKeys = array_keys($extraFields); diff --git a/module/CLI/src/Input/VisitsDateRangeInput.php b/module/CLI/src/Input/VisitsListInput.php similarity index 90% rename from module/CLI/src/Input/VisitsDateRangeInput.php rename to module/CLI/src/Input/VisitsListInput.php index 189fa0b7..c550d4ff 100644 --- a/module/CLI/src/Input/VisitsDateRangeInput.php +++ b/module/CLI/src/Input/VisitsListInput.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Attribute\Option; use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Core\normalizeOptionalDate; -class VisitsDateRangeInput +class VisitsListInput { #[Option('Only return visits older than this date', shortcut: 's')] public string|null $startDate = null; @@ -18,7 +18,7 @@ class VisitsDateRangeInput #[Option('Only return visits newer than this date', shortcut: 'e')] public string|null $endDate = null; - public function toDateRange(): DateRange + public function dateRange(): DateRange { return buildDateRange( startDate: normalizeOptionalDate($this->startDate), From c0edcd3cfd137d312632fc8aec3c35a0d9863166 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 29 Dec 2025 10:22:50 +0100 Subject: [PATCH 64/71] Support paginating the output of visits commands to avoid out of memory errors --- .../Command/Domain/GetDomainVisitsCommand.php | 5 +- .../ShortUrl/GetShortUrlVisitsCommand.php | 4 +- .../src/Command/Tag/GetTagVisitsCommand.php | 4 +- .../Visit/GetNonOrphanVisitsCommand.php | 5 +- .../Command/Visit/GetOrphanVisitsCommand.php | 5 +- .../src/Command/Visit/VisitsCommandUtils.php | 69 +++++++++++++++++-- module/CLI/src/Input/VisitsListFormat.php | 18 +++++ module/CLI/src/Input/VisitsListInput.php | 7 ++ .../Domain/GetDomainVisitsCommandTest.php | 2 +- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 2 +- .../Command/Tag/GetTagVisitsCommandTest.php | 2 +- .../Visit/GetNonOrphanVisitsCommandTest.php | 2 +- .../Visit/GetOrphanVisitsCommandTest.php | 2 +- 13 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 module/CLI/src/Input/VisitsListFormat.php diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index 19790e3a..ac05ee7b 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; use Shlinkio\Shlink\CLI\Input\VisitsListInput; -use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; @@ -37,9 +36,7 @@ class GetDomainVisitsCommand extends Command #[MapInput] VisitsListInput $input, ): int { $paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange())); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - - ShlinkTable::default($io)->render($headers, $rows); + VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); return self::SUCCESS; } diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 54f6c019..b0e4cce0 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; use Shlinkio\Shlink\CLI\Input\VisitsListInput; -use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; @@ -39,9 +38,8 @@ class GetShortUrlVisitsCommand extends Command $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); $dateRange = $input->dateRange(); $paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator); - ShlinkTable::default($io)->render($headers, $rows); + VisitsCommandUtils::renderOutput($io, $input, $paginator); return self::SUCCESS; } diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index 426e253e..529fb536 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; use Shlinkio\Shlink\CLI\Input\VisitsListInput; -use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -47,9 +46,8 @@ class GetTagVisitsCommand extends Command dateRange: $input->dateRange(), domain: $domain, )); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - ShlinkTable::default($io)->render($headers, $rows); + VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); return self::SUCCESS; } diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 3620bbd3..2ff8aa52 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Input\VisitsListInput; -use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -43,9 +42,7 @@ class GetNonOrphanVisitsCommand extends Command dateRange: $input->dateRange(), domain: $domain, )); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - - ShlinkTable::default($io)->render($headers, $rows); + VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); return self::SUCCESS; } diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index d6c12c5c..d1ce8d66 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Input\VisitsListInput; -use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; @@ -43,9 +42,7 @@ class GetOrphanVisitsCommand extends Command domain: $domain, type: $type, )); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - - ShlinkTable::default($io)->render($headers, $rows); + VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); return self::SUCCESS; } diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php index 57ef2ec6..8089b02c 100644 --- a/module/CLI/src/Command/Visit/VisitsCommandUtils.php +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -4,8 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; +use Shlinkio\Shlink\CLI\Input\VisitsListFormat; +use Shlinkio\Shlink\CLI\Input\VisitsListInput; +use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Symfony\Component\Console\Output\OutputInterface; use function array_keys; use function array_map; @@ -18,14 +23,70 @@ class VisitsCommandUtils * @param Paginator $paginator * @param null|callable(Visit $visits): array $mapExtraFields */ - public static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields = null): array + public static function renderOutput( + OutputInterface $output, + VisitsListInput $inputData, + Paginator $paginator, + callable|null $mapExtraFields = null, + ): void { + if ($inputData->format !== VisitsListFormat::FULL) { + // Avoid running out of memory by loading visits in chunks + $paginator->setMaxPerPage(1000); + } + + match ($inputData->format) { + VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields), + default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields), + }; + } + + /** + * @param Paginator $paginator + * @param null|callable(Visit $visits): array $mapExtraFields + */ + private static function renderCSVOutput( + OutputInterface $output, + Paginator $paginator, + callable|null $mapExtraFields, + ): void { + // TODO + } + + /** + * @param Paginator $paginator + * @param null|callable(Visit $visits): array $mapExtraFields + */ + private static function renderHumanFriendlyOutput( + OutputInterface $output, + Paginator $paginator, + callable|null $mapExtraFields, + ): void { + $page = 1; + do { + $paginator->setCurrentPage($page); + $page++; + + [$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields); + ShlinkTable::default($output)->render( + $headers, + $rows, + footerTitle: PagerfantaUtils::formatCurrentPageMessage($paginator, 'Page %s of %s'), + ); + } while ($paginator->hasNextPage()); + } + + /** + * @param Paginator $paginator + * @param null|callable(Visit $visits): array $mapExtraFields + */ + private static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields): array { - $extraKeys = []; + $extraKeys = null; $mapExtraFields ??= static fn (Visit $_) => []; $rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) { $extraFields = $mapExtraFields($visit); - $extraKeys = array_keys($extraFields); + $extraKeys ??= array_keys($extraFields); $rowData = [ 'referer' => $visit->referer, @@ -40,7 +101,7 @@ class VisitsCommandUtils // Filter out unknown keys return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); }, [...$paginator->getCurrentPageResults()]); - $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys); + $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys ?? []); return [ $rows, diff --git a/module/CLI/src/Input/VisitsListFormat.php b/module/CLI/src/Input/VisitsListFormat.php new file mode 100644 index 00000000..acb95301 --- /dev/null +++ b/module/CLI/src/Input/VisitsListFormat.php @@ -0,0 +1,18 @@ +value . '", "' . VisitsListFormat::PAGINATED->value . '" or "' + . VisitsListFormat::CSV->value . '")', + shortcut: 'f', + )] + public VisitsListFormat $format = VisitsListFormat::FULL; + public function dateRange(): DateRange { return buildDateRange( diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index ddf55283..d5abbb22 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -61,7 +61,7 @@ class GetDomainVisitsCommandTest extends TestCase | Referer | Date | User agent | Country | City | Short Url | +---------+---------------------------+------------+---------+--------+---------------+ | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | - +---------+---------------------------+------------+---------+--------+---------------+ + +---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ OUTPUT, $output, diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 3fd53c48..04a081c0 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -101,7 +101,7 @@ class GetShortUrlVisitsCommandTest extends TestCase | Referer | Date | User agent | Country | City | +---------+---------------------------+------------+---------+--------+ | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | - +---------+---------------------------+------------+---------+--------+ + +---------+------------------ Page 1 of 1 ---------+---------+--------+ OUTPUT, $output, diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index 30580951..426ff29b 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -58,7 +58,7 @@ class GetTagVisitsCommandTest extends TestCase | Referer | Date | User agent | Country | City | Short Url | +---------+---------------------------+------------+---------+--------+---------------+ | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | - +---------+---------------------------+------------+---------+--------+---------------+ + +---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ OUTPUT, $output, diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index f583d063..b6e973a9 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -57,7 +57,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase | Referer | Date | User agent | Country | City | Short Url | +---------+---------------------------+------------+---------+--------+---------------+ | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | - +---------+---------------------------+------------+---------+--------+---------------+ + +---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ OUTPUT, $output, diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index bf453c65..1633eee8 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -55,7 +55,7 @@ class GetOrphanVisitsCommandTest extends TestCase | Referer | Date | User agent | Country | City | Type | +---------+---------------------------+------------+---------+--------+----------+ | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | base_url | - +---------+---------------------------+------------+---------+--------+----------+ + +---------+----------------------- Page 1 of 1 ----+---------+--------+----------+ OUTPUT, $output, From a6286c247a34522cb06e20a1ab5e46c1ca0522eb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 65/71] Allow visits to be generated in CSV format --- composer.json | 1 + .../src/Command/Visit/VisitsCommandUtils.php | 17 ++++++- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 44 +++++++++++++------ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 6b560969..a205e391 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "laminas/laminas-inputfilter": "^2.31", "laminas/laminas-servicemanager": "^3.23", "laminas/laminas-stdlib": "^3.20", + "league/csv": "^9.28", "matomo/matomo-php-tracker": "^3.3", "mezzio/mezzio": "^3.20", "mezzio/mezzio-fastroute": "^3.12", diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php index 8089b02c..0eadc2c4 100644 --- a/module/CLI/src/Command/Visit/VisitsCommandUtils.php +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; +use League\Csv\Writer; use Shlinkio\Shlink\CLI\Input\VisitsListFormat; use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; @@ -49,7 +50,21 @@ class VisitsCommandUtils Paginator $paginator, callable|null $mapExtraFields, ): void { - // TODO + $page = 1; + do { + $paginator->setCurrentPage($page); + + [$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields); + $csv = Writer::fromString(); + if ($page === 1) { + $csv->insertOne($headers); + } + + $csv->insertAll($rows); + $output->write($csv->toString()); + + $page++; + } while ($paginator->hasNextPage()); } /** diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 04a081c0..8e94b043 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -6,10 +6,12 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand; +use Shlinkio\Shlink\CLI\Input\VisitsListFormat; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -80,8 +82,11 @@ class GetShortUrlVisitsCommandTest extends TestCase ]); } - #[Test] - public function outputIsProperlyGenerated(): void + /** + * @param callable(Chronos $date): string $getExpectedOutput + */ + #[Test, DataProvider('provideOutput')] + public function outputIsProperlyGenerated(VisitsListFormat $format, callable $getExpectedOutput): void { $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), @@ -92,19 +97,32 @@ class GetShortUrlVisitsCommandTest extends TestCase $this->anything(), )->willReturn(new Paginator(new ArrayAdapter([$visit]))); - $this->commandTester->execute(['short-code' => $shortCode]); + $this->commandTester->execute(['short-code' => $shortCode, '--format' => $format->value]); $output = $this->commandTester->getDisplay(); - self::assertEquals( - <<date->toAtomString()} | bar | Spain | Madrid | - +---------+------------------ Page 1 of 1 ---------+---------+--------+ + self::assertEquals($getExpectedOutput($visit->date), $output); + } - OUTPUT, - $output, - ); + public static function provideOutput(): iterable + { + yield 'regular' => [ + VisitsListFormat::FULL, + static fn (Chronos $date) => <<toAtomString()} | bar | Spain | Madrid | + +---------+------------------ Page 1 of 1 ---------+---------+--------+ + + OUTPUT, + ]; + yield 'CSV' => [ + VisitsListFormat::CSV, + static fn (Chronos $date) => <<toAtomString()},bar,Spain,Madrid + + OUTPUT, + ]; } } From 248e8032e3b927935d1aa238995b553d1b3c3b45 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 29 Dec 2025 11:04:10 +0100 Subject: [PATCH 66/71] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de5c2dc2..4f3d4b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * `after-date`: matches when current date and time is later than the defined threshold. * [#2513](https://github.com/shlinkio/shlink/issues/2513) Add support for redis connections via unix socket (e.g. `REDIS_SERVERS=unix:/path/to/redis.sock`). +* Visits generated in the command line can now be formatted in CSV, via `--format=csv`. ### Changed * [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue. From 900de9e8008119a76529a2a6dd4f133782bc7c57 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 3 Jan 2026 11:45:29 +0100 Subject: [PATCH 67/71] Extend and normalize output from visits console commands --- CHANGELOG.md | 4 + module/CLI/config/dependencies.config.php | 6 +- .../Command/Domain/GetDomainVisitsCommand.php | 19 +---- .../src/Command/Tag/GetTagVisitsCommand.php | 19 +---- .../Visit/GetNonOrphanVisitsCommand.php | 19 +---- .../Command/Visit/GetOrphanVisitsCommand.php | 11 +-- .../src/Command/Visit/VisitsCommandUtils.php | 85 +++++++++---------- .../Domain/GetDomainVisitsCommandTest.php | 25 +++--- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 16 ++-- .../Command/Tag/GetTagVisitsCommandTest.php | 23 +++-- .../Visit/GetNonOrphanVisitsCommandTest.php | 23 +++-- .../Visit/GetOrphanVisitsCommandTest.php | 13 +-- module/Core/functions/array-utils.php | 31 ------- module/Core/src/Matomo/MatomoVisitSender.php | 2 +- module/Core/src/Visit/Entity/Visit.php | 7 +- .../src/Visit/Geolocation/VisitLocator.php | 2 +- 16 files changed, 105 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3d4b4a..b4750c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink. +* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently. + + Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not. + * [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0. * [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable. diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index a9bb47f2..365b094e 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -107,7 +107,7 @@ return [ ], Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class], - Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], + Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class], @@ -119,11 +119,11 @@ return [ Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], - Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], + Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\Domain\ListDomainsCommand::class => [DomainService::class], Command\Domain\DomainRedirectsCommand::class => [DomainService::class], - Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], + Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\RedirectRule\ManageRedirectRulesCommand::class => [ ShortUrl\ShortUrlResolver::class, diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index ac05ee7b..9f4ada0f 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; use Shlinkio\Shlink\CLI\Input\VisitsListInput; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Symfony\Component\Console\Attribute\Argument; @@ -22,10 +20,8 @@ class GetDomainVisitsCommand extends Command { public const string NAME = 'domain:visits'; - public function __construct( - private readonly VisitsStatsHelperInterface $visitsHelper, - private readonly ShortUrlStringifierInterface $shortUrlStringifier, - ) { + public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) + { parent::__construct(); } @@ -36,17 +32,8 @@ class GetDomainVisitsCommand extends Command #[MapInput] VisitsListInput $input, ): int { $paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange())); - VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); + VisitsCommandUtils::renderOutput($io, $input, $paginator); return self::SUCCESS; } - - /** - * @return array - */ - protected function mapExtraFields(Visit $visit): array - { - $shortUrl = $visit->shortUrl; - return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; - } } diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index 529fb536..0719af2b 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils; use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Symfony\Component\Console\Attribute\Argument; @@ -24,10 +22,8 @@ class GetTagVisitsCommand extends Command { public const string NAME = 'tag:visits'; - public function __construct( - private readonly VisitsStatsHelperInterface $visitsHelper, - private readonly ShortUrlStringifierInterface $shortUrlStringifier, - ) { + public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) + { parent::__construct(); } @@ -47,17 +43,8 @@ class GetTagVisitsCommand extends Command domain: $domain, )); - VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); + VisitsCommandUtils::renderOutput($io, $input, $paginator); return self::SUCCESS; } - - /** - * @return array - */ - private function mapExtraFields(Visit $visit): array - { - $shortUrl = $visit->shortUrl; - return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; - } } diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 2ff8aa52..86f5b94e 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -21,10 +19,8 @@ class GetNonOrphanVisitsCommand extends Command { public const string NAME = 'visit:non-orphan'; - public function __construct( - private readonly VisitsStatsHelperInterface $visitsHelper, - private readonly ShortUrlStringifierInterface $shortUrlStringifier, - ) { + public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) + { parent::__construct(); } @@ -42,17 +38,8 @@ class GetNonOrphanVisitsCommand extends Command dateRange: $input->dateRange(), domain: $domain, )); - VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); + VisitsCommandUtils::renderOutput($io, $input, $paginator); return self::SUCCESS; } - - /** - * @return array - */ - private function mapExtraFields(Visit $visit): array - { - $shortUrl = $visit->shortUrl; - return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; - } } diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index d1ce8d66..5ad4a460 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; @@ -42,16 +41,8 @@ class GetOrphanVisitsCommand extends Command domain: $domain, type: $type, )); - VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); + VisitsCommandUtils::renderOutput($io, $input, $paginator); return self::SUCCESS; } - - /** - * @return array - */ - private function mapExtraFields(Visit $visit): array - { - return ['type' => $visit->type->value]; - } } diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php index 0eadc2c4..765b27c0 100644 --- a/module/CLI/src/Command/Visit/VisitsCommandUtils.php +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -13,16 +13,12 @@ use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Symfony\Component\Console\Output\OutputInterface; -use function array_keys; use function array_map; -use function Shlinkio\Shlink\Core\ArrayUtils\select_keys; -use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly; class VisitsCommandUtils { /** * @param Paginator $paginator - * @param null|callable(Visit $visits): array $mapExtraFields */ public static function renderOutput( OutputInterface $output, @@ -36,25 +32,21 @@ class VisitsCommandUtils } match ($inputData->format) { - VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields), - default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields), + VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator), + default => self::renderHumanFriendlyOutput($output, $paginator), }; } /** * @param Paginator $paginator - * @param null|callable(Visit $visits): array $mapExtraFields */ - private static function renderCSVOutput( - OutputInterface $output, - Paginator $paginator, - callable|null $mapExtraFields, - ): void { + private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void + { $page = 1; do { $paginator->setCurrentPage($page); - [$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields); + [$rows, $headers] = self::resolveRowsAndHeaders($paginator); $csv = Writer::fromString(); if ($page === 1) { $csv->insertOne($headers); @@ -69,19 +61,15 @@ class VisitsCommandUtils /** * @param Paginator $paginator - * @param null|callable(Visit $visits): array $mapExtraFields */ - private static function renderHumanFriendlyOutput( - OutputInterface $output, - Paginator $paginator, - callable|null $mapExtraFields, - ): void { + private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void + { $page = 1; do { $paginator->setCurrentPage($page); $page++; - [$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields); + [$rows, $headers] = self::resolveRowsAndHeaders($paginator); ShlinkTable::default($output)->render( $headers, $rows, @@ -92,35 +80,38 @@ class VisitsCommandUtils /** * @param Paginator $paginator - * @param null|callable(Visit $visits): array $mapExtraFields */ - private static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields): array + private static function resolveRowsAndHeaders(Paginator $paginator): array { - $extraKeys = null; - $mapExtraFields ??= static fn (Visit $_) => []; - - $rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) { - $extraFields = $mapExtraFields($visit); - $extraKeys ??= array_keys($extraFields); - - $rowData = [ - 'referer' => $visit->referer, - 'date' => $visit->date->toAtomString(), - 'userAgent' => $visit->userAgent, - 'potentialBot' => $visit->potentialBot, - 'country' => $visit->getVisitLocation()->countryName ?? 'Unknown', - 'city' => $visit->getVisitLocation()->cityName ?? 'Unknown', - ...$extraFields, - ]; - - // Filter out unknown keys - return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); - }, [...$paginator->getCurrentPageResults()]); - $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys ?? []); - - return [ - $rows, - ['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra], + $headers = [ + 'Date', + 'Potential bot', + 'User agent', + 'Referer', + 'Country', + 'Region', + 'City', + 'Visited URL', + 'Redirect URL', + 'Type', ]; + $rows = array_map(function (Visit $visit) { + $visitLocation = $visit->visitLocation; + + return [ + 'date' => $visit->date->toAtomString(), + 'potentialBot' => $visit->potentialBot ? 'Potential bot' : '', + 'userAgent' => $visit->userAgent, + 'referer' => $visit->referer, + 'country' => $visitLocation->countryName ?? 'Unknown', + 'region' => $visitLocation->regionName ?? 'Unknown', + 'city' => $visitLocation->cityName ?? 'Unknown', + 'visitedUrl' => $visit->visitedUrl ?? 'Unknown', + 'redirectUrl' => $visit->redirectUrl ?? 'Unknown', + 'type' => $visit->type->value, + ]; + }, [...$paginator->getCurrentPageResults()]); + + return [$rows, $headers]; } } diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index d5abbb22..9b4e2f3f 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; @@ -24,16 +24,11 @@ class GetDomainVisitsCommandTest extends TestCase { private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; - private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); - $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); - - $this->commandTester = CliTestUtils::testerForCommand( - new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier), - ); + $this->commandTester = CliTestUtils::testerForCommand(new GetDomainVisitsCommand($this->visitsHelper)); } #[Test] @@ -48,22 +43,22 @@ class GetDomainVisitsCommandTest extends TestCase $domain, $this->anything(), )->willReturn(new Paginator(new ArrayAdapter([$visit]))); - $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( - 'the_short_url', - ); $this->commandTester->execute(['domain' => $domain]); $output = $this->commandTester->getDisplay(); + $type = VisitType::VALID_SHORT_URL->value; self::assertEquals( + // phpcs:disable Generic.Files.LineLength <<date->toAtomString()} | bar | Spain | Madrid | the_short_url | - +---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type | + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} | + +---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+ OUTPUT, + // phpcs:enable $output, ); } diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 8e94b043..9fff4607 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -107,20 +107,22 @@ class GetShortUrlVisitsCommandTest extends TestCase { yield 'regular' => [ VisitsListFormat::FULL, + // phpcs:disable Generic.Files.LineLength static fn (Chronos $date) => <<toAtomString()} | bar | Spain | Madrid | - +---------+------------------ Page 1 of 1 ---------+---------+--------+ + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type | + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | {$date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | valid_short_url | + +---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+ OUTPUT, + // phpcs:enable ]; yield 'CSV' => [ VisitsListFormat::CSV, static fn (Chronos $date) => <<toAtomString()},bar,Spain,Madrid + Date,"Potential bot","User agent",Referer,Country,Region,City,"Visited URL","Redirect URL",Type + {$date->toAtomString()},,bar,foo,Spain,,Madrid,,Unknown,valid_short_url OUTPUT, ]; diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index 426ff29b..5e280d5c 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; @@ -24,16 +24,11 @@ class GetTagVisitsCommandTest extends TestCase { private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; - private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); - $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); - - $this->commandTester = CliTestUtils::testerForCommand( - new GetTagVisitsCommand($this->visitsHelper, $this->stringifier), - ); + $this->commandTester = CliTestUtils::testerForCommand(new GetTagVisitsCommand($this->visitsHelper)); } #[Test] @@ -47,20 +42,22 @@ class GetTagVisitsCommandTest extends TestCase $this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn( new Paginator(new ArrayAdapter([$visit])), ); - $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url'); $this->commandTester->execute(['tag' => $tag]); $output = $this->commandTester->getDisplay(); + $type = VisitType::VALID_SHORT_URL->value; self::assertEquals( + // phpcs:disable Generic.Files.LineLength <<date->toAtomString()} | bar | Spain | Madrid | the_short_url | - +---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type | + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} | + +---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+ OUTPUT, + // phpcs:enable $output, ); } diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index b6e973a9..31042c82 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; @@ -24,16 +24,11 @@ class GetNonOrphanVisitsCommandTest extends TestCase { private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; - private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); - $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); - - $this->commandTester = CliTestUtils::testerForCommand( - new GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier), - ); + $this->commandTester = CliTestUtils::testerForCommand(new GetNonOrphanVisitsCommand($this->visitsHelper)); } #[Test] @@ -46,20 +41,22 @@ class GetNonOrphanVisitsCommandTest extends TestCase $this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn( new Paginator(new ArrayAdapter([$visit])), ); - $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url'); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); + $type = VisitType::VALID_SHORT_URL->value; self::assertEquals( + // phpcs:disable Generic.Files.LineLength <<date->toAtomString()} | bar | Spain | Madrid | the_short_url | - +---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type | + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+ + | {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} | + +---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+ OUTPUT, + // phpcs:enable $output, ); } diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 1633eee8..28f9531b 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -48,16 +48,19 @@ class GetOrphanVisitsCommandTest extends TestCase $this->commandTester->execute($args); $output = $this->commandTester->getDisplay(); + $type = OrphanVisitType::BASE_URL->value; self::assertEquals( + // phpcs:disable Generic.Files.LineLength <<date->toAtomString()} | bar | Spain | Madrid | base_url | - +---------+----------------------- Page 1 of 1 ----+---------+--------+----------+ + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+----------+ + | Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type | + +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+----------+ + | {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} | + +---------------------------+---------------+------------+--- Page 1 of 1 ---+--------+--------+-------------+--------------+----------+ OUTPUT, + // phpcs:enable $output, ); } diff --git a/module/Core/functions/array-utils.php b/module/Core/functions/array-utils.php index 3e0010a2..fe2ffae6 100644 --- a/module/Core/functions/array-utils.php +++ b/module/Core/functions/array-utils.php @@ -4,12 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ArrayUtils; -use function array_filter; -use function array_reduce; use function in_array; -use const ARRAY_FILTER_USE_KEY; - /** * @template T * @param T $value @@ -20,18 +16,6 @@ function contains(mixed $value, array $array): bool return in_array($value, $array, strict: true); } -/** - * @param array[] $multiArray - */ -function flatten(array $multiArray): array -{ - return array_reduce( - $multiArray, - static fn (array $carry, array $value) => [...$carry, ...$value], - initial: [], - ); -} - /** * Checks if a callback returns true for at least one item in a collection. * @param callable(mixed $value, mixed $key): bool $callback @@ -62,21 +46,6 @@ function every(iterable $collection, callable $callback): bool return true; } -/** - * Returns an array containing only those entries in the array whose key is in the supplied keys. - */ -function select_keys(array $array, array $keys): array -{ - return array_filter( - $array, - static fn (string $key) => contains( - $key, - $keys, - ), - ARRAY_FILTER_USE_KEY, - ); -} - /** * @template T * @template R diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php index 6a32c2a5..bd2bf848 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -58,7 +58,7 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface ->setUrlReferrer($visit->referer) ->setForceVisitDateTime($visit->date->setTimezone('UTC')->toDateTimeString()); - $location = $visit->getVisitLocation(); + $location = $visit->visitLocation; if ($location !== null) { $tracker ->setCity($location->cityName) diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index b396bc07..1cd76c7e 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -29,7 +29,7 @@ class Visit extends AbstractEntity implements JsonSerializable public readonly string|null $remoteAddr = null, public readonly string|null $visitedUrl = null, public readonly string|null $redirectUrl = null, - private VisitLocation|null $visitLocation = null, + private(set) VisitLocation|null $visitLocation = null, public readonly Chronos $date = new Chronos(), ) { } @@ -124,11 +124,6 @@ class Visit extends AbstractEntity implements JsonSerializable return ! empty($this->remoteAddr); } - public function getVisitLocation(): VisitLocation|null - { - return $this->visitLocation; - } - public function locate(VisitLocation $visitLocation): self { $this->visitLocation = $visitLocation; diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index 48245a29..5480a53d 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -72,7 +72,7 @@ readonly class VisitLocator implements VisitLocatorInterface private function locateVisit(Visit $visit, VisitLocation $location, VisitGeolocationHelperInterface $helper): void { - $prevLocation = $visit->getVisitLocation(); + $prevLocation = $visit->visitLocation; $visit->locate($location); $this->em->persist($visit); From 9641c704e27d484c679ddc685ddcbb7b6ff8722e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 7 Jan 2026 19:38:13 +0100 Subject: [PATCH 68/71] Remove dependency in ext-json --- CHANGELOG.md | 1 + composer.json | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4750c06..3cfe0a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag. * [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead. * Remove support to provide redis database index via URI path. Use `?database=3` query instead. +* [#2565](https://github.com/shlinkio/shlink/issues/2565) Remove explicit dependency in ext-json, since it's part of PHP since v8.0 ### Fixed * *Nothing* diff --git a/composer.json b/composer.json index a205e391..e21a021f 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,6 @@ "require": { "php": "^8.4", "ext-curl": "*", - "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.6", @@ -45,10 +44,10 @@ "shlinkio/shlink-common": "dev-main#d4ae052 as 8.0.0", "shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0", "shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0", - "shlinkio/shlink-importer": "dev-main#af03f6b as 5.7.0", + "shlinkio/shlink-importer": "dev-main#63753cf as 5.7.0", "shlinkio/shlink-installer": "dev-develop#a225b16 as 10.0.0", "shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0", - "shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0", + "shlinkio/shlink-json": "^1.3", "spiral/roadrunner": "^2025.1", "spiral/roadrunner-cli": "^2.7", "spiral/roadrunner-http": "^3.6", From 97e7d4a7fe84a49a64c35247a6e2200fa5394dff Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 9 Jan 2026 14:09:01 +0100 Subject: [PATCH 69/71] Document how to upgrade to Shlink 5.0 --- CHANGELOG.md | 2 +- UPGRADE.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfe0a2e..6c684a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard. * [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead. * [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name. -* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` deprecated options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead. +* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead. * [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag. * [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead. * Remove support to provide redis database index via URI path. Use `?database=3` query instead. diff --git a/UPGRADE.md b/UPGRADE.md index bbb7c3a4..556fcd18 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,26 @@ # Upgrading +## From v4.x to v5.x + +### General + +* Generating QR codes by appending `/qr-code` to a short URL is no longer possible. Use external services to generate QR codes from a short URL, or the logic embedded in Shlink Web Client and Shlink Dashboard. +* Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address. + Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink. +* PHP 8.3 is no longer supported. Only 8.4 and 8.5 are officially supported as of Shlink 5.0.0. + +### Changes in CLI + +* Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name. +* All visits-related commands (`short-url:visits`, `tag:visits`, `domain:visits`, `visit:orphan` and `visit:non-orphan`) now return more information, and columns are arranged slightly differently. +* The `short-url:list` command no longer accepts `--including-all-tags` and `--show-api-key-name` options. Use `--tags-all` and `--show-api-key` instead. +* The `short-url:list` command no longer allows ordering using the `--order-by=field,dir` format. Use `--order-by=field-dir` instead. +* All commands which used to accept the `--tags` flag, no longer accept it. Pass `--tag` multiple times instead, one per tag. + +### Changes in env vars + +* The `REDIRECT_APPEND_EXTRA_PATH` env var is no longer supported. Use `REDIRECT_EXTRA_PATH_MODE=append` to enable the same behavior. + ## From v3.x to v4.x ### General From 8a82361d0edc995fd4689ba950957b22abe22a6d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 9 Jan 2026 17:05:51 +0100 Subject: [PATCH 70/71] Fix error when trying to persist non-utf-8 title --- CHANGELOG.md | 7 ++- .../Helper/ShortUrlTitleResolutionHelper.php | 62 +++++++++++++++---- .../ShortUrlTitleResolutionHelperTest.php | 44 +++++++++++-- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c684a4e..991679d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2565](https://github.com/shlinkio/shlink/issues/2565) Remove explicit dependency in ext-json, since it's part of PHP since v8.0 ### Fixed -* *Nothing* +* [#2564](https://github.com/shlinkio/shlink/issues/2564) Fix error when trying to persist non-utf-8 title without being able to determine its original charset for parsing. + + Now, when resolving a website's charset, two improvements have been introduced: + + 1. If the `Content-Type` header does not define the charset, we fall back to `` or ``. + 2. If it's still not possible to determine the charset, we ignore the auto-resolved title, to avoid other encoding errors further down the line. ## [4.6.0] - 2025-11-01 diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index f870bb87..496708d9 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -10,6 +10,7 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\RequestOptions; use Laminas\Stdlib\ErrorHandler; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Throwable; @@ -30,10 +31,12 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH public const string CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' . 'Chrome/121.0.0.0 Safari/537.36'; - // Matches the value inside a html title tag + /** Matches the value inside a html title tag */ private const string TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; - // Matches the charset inside a Content-Type header + /** Matches the charset inside a Content-Type header */ private const string CHARSET_VALUE = '/charset=([^;]+)/i'; + /** Matches the charset from charset-related tags */ + private const string CHARSET_FROM_META = '/]*\bcharset\s*=\s*(?:["\']?)([^"\'\s>;]+)/i'; /** * @param (Closure(): bool)|null $isIconvInstalled @@ -83,7 +86,8 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH RequestOptions::IDN_CONVERSION => true, // Making the request with a browser's user agent results in responses closer to a real user RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT], - RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed + // This ensures large files are not fully downloaded if not needed + RequestOptions::STREAM => true, ]); } catch (Throwable) { return null; @@ -102,24 +106,56 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH // Try to match the title from the tag preg_match(self::TITLE_TAG_VALUE, $collectedBody, $titleMatches); - if (! isset($titleMatches[1])) { + $titleInOriginalEncoding = $titleMatches[1] ?? null; + if ($titleInOriginalEncoding === null) { + return null; + } + ; + $pageCharset = $this->resolvePageCharset($contentType, $body, $collectedBody); + if ($pageCharset === null) { + // If it was not possible to determine the page's charset, ignore the title to avoid the risk of encoding + // errors when the value is persisted return null; } - $titleInOriginalEncoding = $titleMatches[1]; - - // Get the page's charset from Content-Type header, or return title as is if not found - preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches); - if (! isset($charsetMatches[1])) { - return $titleInOriginalEncoding; - } - - $pageCharset = $charsetMatches[1]; return $this->encodeToUtf8WithMbString($titleInOriginalEncoding, $pageCharset) ?? $this->encodeToUtf8WithIconv($titleInOriginalEncoding, $pageCharset) ?? $titleInOriginalEncoding; } + /** + * Tries to resolve the page's charset by looking into the: + * 1. Content-Type header + * 2. <meta charset="???"> tag + * 3. <meta http-equiv="Content-Type" content="text/html; charset=???"> tag + * + * @param StreamInterface $body - The body stream, in case we need to continue reading from it + * @param string $collectedBody - The part of the body that has already been read while looking for the title + */ + private function resolvePageCharset(string $contentType, StreamInterface $body, string $collectedBody): string|null + { + // First try to resolve the charset from the `Content-Type` header + preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches); + $pageCharset = $charsetMatches[1] ?? null; + if ($pageCharset !== null) { + return $pageCharset; + } + + $readCharsetFromMeta = static function (string $collectedBody): string|null { + preg_match(self::CHARSET_FROM_META, $collectedBody, $charsetFromMetaMatches); + return $charsetFromMetaMatches[1] ?? null; + }; + + // Continue reading the body, looking for any of the charset meta tags + $charsetFromMeta = $readCharsetFromMeta($collectedBody); + while ($charsetFromMeta === null && ! $body->eof()) { + $collectedBody .= $body->read(1024); + $charsetFromMeta = $readCharsetFromMeta($collectedBody); + } + + return $charsetFromMeta; + } + private function encodeToUtf8WithMbString(string $titleInOriginalEncoding, string $pageCharset): string|null { try { diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index 857c4291..b1f2e0d0 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -11,6 +11,7 @@ use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Response; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Stream; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\InvocationStubber; @@ -23,7 +24,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; class ShortUrlTitleResolutionHelperTest extends TestCase { - private const string LONG_URL = 'http://foobar.com/12345/hello?foo=bar'; + private const string LONG_URL = 'https://foobar.com/12345/hello?foo=bar'; private MockObject & ClientInterface $httpClient; private MockObject & LoggerInterface $logger; @@ -98,7 +99,6 @@ class ShortUrlTitleResolutionHelperTest extends TestCase } #[Test] - #[TestWith(['TEXT/html', false], 'no charset')] #[TestWith(['TEXT/html; charset=utf-8', false], 'mbstring-supported charset')] #[TestWith(['TEXT/html; charset=Windows-1255', true], 'mbstring-unsupported charset')] public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType, bool $expectsWarning): void @@ -120,6 +120,37 @@ class ShortUrlTitleResolutionHelperTest extends TestCase self::assertEquals('Resolved "title"', $result->title); } + #[Test, AllowMockObjectsWithoutExpectations] + public function resolvedTitleIsIgnoredWhenCharsetCannotBeResolved(): void + { + $this->expectRequestToBeCalled()->willReturn($this->respWithTitle('text/html')); + + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data); + + self::assertSame($data, $result); + self::assertNull($result->title); + } + + #[Test, AllowMockObjectsWithoutExpectations] + #[TestWith(['<meta charset="utf-8">'])] + #[TestWith(['<meta charset="utf-8" />'])] + #[TestWith(['<meta http-equiv="Content-Type" content="text/html; charset=utf-8">'])] + #[TestWith(['<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />'])] + public function pageCharsetCanBeReadFromMeta(string $extraContent): void + { + $this->expectRequestToBeCalled()->willReturn($this->respWithTitle( + contentType: 'text/html', + extraContent: $extraContent, + )); + + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data); + + self::assertNotSame($data, $result); + self::assertEquals('Resolved "title"', $result->title); + } + #[Test] #[TestWith([ 'contentType' => 'text/html; charset=Windows-1255', @@ -178,9 +209,14 @@ class ShortUrlTitleResolutionHelperTest extends TestCase return new Response($body, 200, ['Content-Type' => 'text/html']); } - private function respWithTitle(string $contentType): Response + private function respWithTitle(string $contentType, string|null $extraContent = null): Response { - $body = $this->createStreamWithContent('<title data-foo="bar"> Resolved "title" '); + $content = ' Resolved "title" '; + if ($extraContent !== null) { + $content .= $extraContent; + } + + $body = $this->createStreamWithContent($content); return new Response($body, 200, ['Content-Type' => $contentType]); } From 1cb93f61548bc8151883a4228b8e112d8eedba03 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 9 Jan 2026 17:14:11 +0100 Subject: [PATCH 71/71] Add v5.0.0 to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 991679d2..9120101f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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). -## [Unreleased] +## [5.0.0] - 2026-01-09 ### Added * [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based conditions for the dynamic rules redirections system, that allow to perform redirections based on an ISO-8601 date value.