mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-25 18:45:27 -06:00
2
.github/actions/ci-setup/action.yml
vendored
2
.github/actions/ci-setup/action.yml
vendored
@@ -43,5 +43,5 @@ runs:
|
||||
coverage: xdebug
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install-deps == 'yes' }}
|
||||
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
shell: bash
|
||||
|
||||
1
.github/workflows/ci-db-tests.yml
vendored
1
.github/workflows/ci-db-tests.yml
vendored
@@ -14,7 +14,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
|
||||
1
.github/workflows/ci-tests.yml
vendored
1
.github/workflows/ci-tests.yml
vendored
@@ -14,7 +14,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
continue-on-error: ${{ matrix.php-version == '8.4' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3'] # TODO 8.4
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -4,12 +4,58 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.3.0] - 2024-11-24
|
||||
### Added
|
||||
* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4.
|
||||
* [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it.
|
||||
* [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`.
|
||||
|
||||
This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag.
|
||||
|
||||
* [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system.
|
||||
|
||||
* `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor.
|
||||
* `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor.
|
||||
|
||||
* [#2032](https://github.com/shlinkio/shlink/issues/2032) Save the URL to which a visitor is redirected when a visit is tracked.
|
||||
|
||||
The value is exposed in the API as a new `redirectUrl` field for visit objects.
|
||||
|
||||
This is useful to know where a visitor was redirected for a short URL with dynamic redirect rules, for special redirects, or simply in case the long URL was changed over time, and you still want to know where visitors were redirected originally.
|
||||
|
||||
Some visits may not have a redirect URL if a redirect didn't happen, like for orphan visits when no special redirects are configured, or when a visit is tracked as part of the pixel action.
|
||||
|
||||
### Changed
|
||||
* [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text.
|
||||
|
||||
As a side effect, API key names have now become more important, and are considered unique.
|
||||
|
||||
When people update to this Shlink version, existing API keys will be hashed for everything to continue working.
|
||||
|
||||
In order to avoid data to be lost, plain-text keys will be written in the `name` field, either together with any existing name, or as the name itself. Then users are responsible for renaming them using the new `api-key:rename` command.
|
||||
|
||||
For newly created API keys, it is recommended to provide a name, but if not provided, a name will be generated from a redacted version of the new API key.
|
||||
|
||||
* Update to Shlink PHP coding standard 2.4
|
||||
* Update to `hidehalo/nanoid-php` 2.0
|
||||
* Update to PHPStan 2.0
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2264](https://github.com/shlinkio/shlink/issues/2264) Fix visits counts not being deleted when deleting short URL or orphan visits.
|
||||
|
||||
|
||||
## [4.2.5] - 2024-11-03
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
* Update to Shlink PHP coding standard 2.4
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM php:8.3-alpine3.19 as base
|
||||
FROM php:8.3-alpine3.20 AS base
|
||||
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
@@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
|
||||
|
||||
ENV USER_ID '1001'
|
||||
ENV PDO_SQLSRV_VERSION 5.12.0
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
|
||||
ENV LC_ALL 'C'
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
|
||||
|
||||
[](https://fosstodon.org/@shlinkio)
|
||||
[](https://bsky.app/profile/shlinkio.bsky.social)
|
||||
[](https://bsky.app/profile/shlink.io)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.3",
|
||||
"acelaya/crawler-detect": "^1.3",
|
||||
"acelaya/ip-address-middleware": "^2.4",
|
||||
"cakephp/chronos": "^3.1",
|
||||
"doctrine/dbal": "^4.2",
|
||||
"doctrine/migrations": "^3.8",
|
||||
@@ -27,9 +28,7 @@
|
||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||
"geoip2/geoip2": "^3.0",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"hidehalo/nanoid-php": "^1.1",
|
||||
"jaybizzle/crawler-detect": "^1.2.116",
|
||||
"laminas/laminas-config": "^3.9",
|
||||
"hidehalo/nanoid-php": "^2.0",
|
||||
"laminas/laminas-config-aggregator": "^1.15",
|
||||
"laminas/laminas-diactoros": "^3.5",
|
||||
"laminas/laminas-inputfilter": "^2.30",
|
||||
@@ -39,17 +38,17 @@
|
||||
"mezzio/mezzio": "^3.20",
|
||||
"mezzio/mezzio-fastroute": "^3.12",
|
||||
"mezzio/mezzio-problem-details": "^1.15",
|
||||
"mlocati/ip-lib": "^1.18",
|
||||
"mobiledetect/mobiledetectlib": "^4.8",
|
||||
"mlocati/ip-lib": "^1.18.1",
|
||||
"mobiledetect/mobiledetectlib": "4.8.x-dev#920c549 as 4.9",
|
||||
"pagerfanta/core": "^3.8",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.1.1",
|
||||
"shlinkio/shlink-common": "^6.5",
|
||||
"shlinkio/shlink-config": "^3.3",
|
||||
"shlinkio/shlink-common": "^6.6",
|
||||
"shlinkio/shlink-config": "^3.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.1",
|
||||
"shlinkio/shlink-importer": "^5.3.2",
|
||||
"shlinkio/shlink-installer": "^9.2",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.1",
|
||||
"shlinkio/shlink-installer": "^9.3",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.2",
|
||||
"shlinkio/shlink-json": "^1.1",
|
||||
"spiral/roadrunner": "^2024.1",
|
||||
"spiral/roadrunner-cli": "^2.6",
|
||||
@@ -64,16 +63,16 @@
|
||||
"require-dev": {
|
||||
"devizzent/cebe-php-openapi": "^1.0.1",
|
||||
"devster/ubench": "^2.1",
|
||||
"phpstan/phpstan": "^1.12",
|
||||
"phpstan/phpstan-doctrine": "^1.5",
|
||||
"phpstan/phpstan-phpunit": "^1.4",
|
||||
"phpstan/phpstan-symfony": "^1.4",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/php-code-coverage": "^11.0",
|
||||
"phpunit/phpcov": "^10.0",
|
||||
"phpunit/phpunit": "^11.4",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^4.1.1",
|
||||
"shlinkio/php-coding-standard": "~2.4.0",
|
||||
"shlinkio/shlink-test-utils": "^4.2",
|
||||
"symfony/var-dumper": "^7.1",
|
||||
"veewee/composer-run-parallel": "^1.4"
|
||||
},
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'ip_address_resolution' => [
|
||||
'headers_to_inspect' => [
|
||||
'CF-Connecting-IP',
|
||||
'X-Forwarded-For',
|
||||
'X-Forwarded',
|
||||
'Forwarded',
|
||||
'True-Client-IP',
|
||||
'X-Real-IP',
|
||||
'X-Cluster-Client-Ip',
|
||||
'Client-Ip',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
37
config/autoload/ip-address.global.php
Normal file
37
config/autoload/ip-address.global.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use RKA\Middleware\IpAddress;
|
||||
use RKA\Middleware\Mezzio\IpAddressFactory;
|
||||
|
||||
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
|
||||
|
||||
return [
|
||||
|
||||
// Configuration for RKA\Middleware\IpAddress
|
||||
'rka' => [
|
||||
'ip_address' => [
|
||||
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
|
||||
'check_proxy_headers' => true,
|
||||
'trusted_proxies' => [],
|
||||
'headers_to_inspect' => [
|
||||
'CF-Connecting-IP',
|
||||
'X-Forwarded-For',
|
||||
'X-Forwarded',
|
||||
'Forwarded',
|
||||
'True-Client-IP',
|
||||
'X-Real-IP',
|
||||
'X-Cluster-Client-Ip',
|
||||
'Client-Ip',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
IpAddress::class => IpAddressFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -11,6 +11,7 @@ use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
|
||||
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
|
||||
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
|
||||
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
|
||||
|
||||
return [
|
||||
|
||||
@@ -67,8 +68,11 @@ return [
|
||||
],
|
||||
'not-found' => [
|
||||
'middleware' => [
|
||||
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
|
||||
// These two middlewares are in front of other tracking actions.
|
||||
// Putting them here for orphan visits tracking
|
||||
IpAddress::class,
|
||||
IpGeolocationMiddleware::class,
|
||||
|
||||
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
|
||||
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
|
||||
|
||||
@@ -8,6 +8,7 @@ use Fig\Http\Message\RequestMethodInterface;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Action as CoreAction;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware;
|
||||
use Shlinkio\Shlink\Rest\Action;
|
||||
use Shlinkio\Shlink\Rest\ConfigProvider;
|
||||
@@ -88,6 +89,7 @@ return (static function (): array {
|
||||
'path' => '/{shortCode}/track',
|
||||
'middleware' => [
|
||||
IpAddress::class,
|
||||
IpGeolocationMiddleware::class,
|
||||
CoreAction\PixelAction::class,
|
||||
],
|
||||
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
|
||||
@@ -105,6 +107,7 @@ return (static function (): array {
|
||||
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),
|
||||
'middleware' => [
|
||||
IpAddress::class,
|
||||
IpGeolocationMiddleware::class,
|
||||
TrimTrailingSlashMiddleware::class,
|
||||
CoreAction\RedirectAction::class,
|
||||
],
|
||||
|
||||
@@ -21,3 +21,5 @@ const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
|
||||
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
|
||||
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
|
||||
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
|
||||
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
|
||||
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
set -ex
|
||||
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
curl https://packages.microsoft.com/config/ubuntu/24.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
apt-get update
|
||||
ACCEPT_EULA=Y apt-get install msodbcsql18
|
||||
# apt-get install unixodbc-dev
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM php:8.3-fpm-alpine3.19
|
||||
FROM php:8.3-fpm-alpine3.20
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.23
|
||||
ENV APCU_VERSION 5.1.24
|
||||
ENV PDO_SQLSRV_VERSION 5.12.0
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
FROM php:8.3-alpine3.19
|
||||
FROM php:8.3-alpine3.20
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.23
|
||||
ENV PDO_SQLSRV_VERSION 5.12.0
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -36,16 +35,6 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
|
||||
apk del .phpize-deps
|
||||
RUN docker-php-ext-install bcmath
|
||||
|
||||
# Install APCu extension
|
||||
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu \
|
||||
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
|
||||
&& docker-php-ext-configure apcu \
|
||||
&& docker-php-ext-install apcu \
|
||||
&& rm /tmp/apcu.tar.gz \
|
||||
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
|
||||
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
|
||||
# Install xdebug and sqlsrv driver
|
||||
RUN apk add --update linux-headers && \
|
||||
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
|
||||
@@ -141,6 +141,14 @@
|
||||
"crawlable": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
|
||||
},
|
||||
"forwardQuery": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
|
||||
},
|
||||
"hasRedirectRules": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules."
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
@@ -164,7 +172,9 @@
|
||||
},
|
||||
"domain": "example.com",
|
||||
"title": "The title",
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": false,
|
||||
"hasRedirectRules": true
|
||||
}
|
||||
},
|
||||
"ShortUrlMeta": {
|
||||
@@ -237,6 +247,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The originally visited URL that triggered the tracking of this visit"
|
||||
},
|
||||
"redirectUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["device", "language", "query-param", "ip-address"],
|
||||
"enum": [
|
||||
"device",
|
||||
"language",
|
||||
"query-param",
|
||||
"ip-address",
|
||||
"geolocation-country-code",
|
||||
"geolocation-city-name"
|
||||
],
|
||||
"description": "The type of the condition, which will determine the logic used to match it"
|
||||
},
|
||||
"matchKey": {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"domain",
|
||||
"title",
|
||||
"crawlable",
|
||||
"forwardQuery"
|
||||
"forwardQuery",
|
||||
"hasRedirectRules"
|
||||
],
|
||||
"properties": {
|
||||
"shortCode": {
|
||||
@@ -59,6 +60,10 @@
|
||||
"forwardQuery": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
|
||||
},
|
||||
"hasRedirectRules": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"visitedUrl": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The originally visited URL that triggered the tracking of this visit"
|
||||
},
|
||||
"redirectUrl": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,15 @@
|
||||
"false"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "query",
|
||||
"description": "Get short URLs for this particular domain only. Use **DEFAULT** keyword for default domain.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -180,7 +189,9 @@
|
||||
},
|
||||
"domain": null,
|
||||
"title": "Welcome to Steam",
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": true,
|
||||
"hasRedirectRules": true
|
||||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
@@ -202,7 +213,9 @@
|
||||
},
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": true,
|
||||
"hasRedirectRules": false
|
||||
},
|
||||
{
|
||||
"shortCode": "123bA",
|
||||
@@ -222,7 +235,9 @@
|
||||
},
|
||||
"domain": "example.com",
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": false,
|
||||
"hasRedirectRules": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
@@ -337,7 +352,9 @@
|
||||
},
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": true,
|
||||
"hasRedirectRules": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@
|
||||
},
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": true,
|
||||
"hasRedirectRules": false
|
||||
}
|
||||
},
|
||||
"text/plain": {
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
},
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": true,
|
||||
"hasRedirectRules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +165,9 @@
|
||||
},
|
||||
"domain": null,
|
||||
"title": "Shlink - The URL shortener",
|
||||
"crawlable": false
|
||||
"crawlable": false,
|
||||
"forwardQuery": false,
|
||||
"hasRedirectRules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ return [
|
||||
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
||||
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
|
||||
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
|
||||
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,
|
||||
|
||||
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
|
||||
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
|
||||
|
||||
@@ -59,6 +59,7 @@ return [
|
||||
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -120,6 +121,7 @@ return [
|
||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],
|
||||
|
||||
Command\Tag\ListTagsCommand::class => [TagService::class],
|
||||
Command\Tag\RenameTagCommand::class => [TagService::class],
|
||||
|
||||
@@ -6,39 +6,99 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function sprintf;
|
||||
|
||||
class DisableKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:disable';
|
||||
|
||||
public function __construct(private ApiKeyServiceInterface $apiKeyService)
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription('Disables an API key.')
|
||||
->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable');
|
||||
$help = <<<HELP
|
||||
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
|
||||
plain-text key.
|
||||
|
||||
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally pass the API key name to be disabled. In that case <comment>--by-name</comment> is also
|
||||
required, to indicate the first argument is the API key name and not the plain-text key:
|
||||
|
||||
<info>%command.full_name% the_key_name --by-name</info>
|
||||
|
||||
You can pass the plain-text key to be disabled, but that is <options=bold>DEPRECATED</>. In next major version,
|
||||
the argument will always be assumed to be the name:
|
||||
|
||||
<info>%command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143</info>
|
||||
|
||||
HELP;
|
||||
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)')
|
||||
->addArgument(
|
||||
'keyOrName',
|
||||
InputArgument::OPTIONAL,
|
||||
'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
|
||||
)
|
||||
->addOption(
|
||||
'by-name',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'Indicates the first argument is the API key name, not the plain-text key.',
|
||||
)
|
||||
->setHelp($help);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$keyOrName = $input->getArgument('keyOrName');
|
||||
|
||||
if ($keyOrName === null) {
|
||||
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
|
||||
$name = (new SymfonyStyle($input, $output))->choice(
|
||||
'What API key do you want to disable?',
|
||||
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
|
||||
);
|
||||
|
||||
$input->setArgument('keyOrName', $name);
|
||||
$input->setOption('by-name', true);
|
||||
}
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$apiKey = $input->getArgument('apiKey');
|
||||
$keyOrName = $input->getArgument('keyOrName');
|
||||
$byName = $input->getOption('by-name');
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if (! $keyOrName) {
|
||||
$io->warning('An API key name was not provided.');
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->apiKeyService->disable($apiKey);
|
||||
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
|
||||
if ($byName) {
|
||||
$this->apiKeyService->disableByName($keyOrName);
|
||||
} else {
|
||||
$this->apiKeyService->disableByKey($keyOrName);
|
||||
}
|
||||
$io->success(sprintf('API key "%s" properly disabled', $keyOrName));
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
@@ -100,23 +100,26 @@ class GenerateKeyCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$expirationDate = $input->getOption('expiration-date');
|
||||
|
||||
$apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams(
|
||||
$apiKeyMeta = ApiKeyMeta::fromParams(
|
||||
name: $input->getOption('name'),
|
||||
expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
|
||||
roleDefinitions: $this->roleResolver->determineRoles($input),
|
||||
));
|
||||
);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
|
||||
$apiKey = $this->apiKeyService->create($apiKeyMeta);
|
||||
$io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key));
|
||||
|
||||
if ($input->isInteractive()) {
|
||||
$io->warning('Save the key in a secure location. You will not be able to get it afterwards.');
|
||||
}
|
||||
|
||||
if (! ApiKey::isAdmin($apiKey)) {
|
||||
ShlinkTable::default($io)->render(
|
||||
['Role name', 'Role metadata'],
|
||||
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
|
||||
null,
|
||||
'Roles',
|
||||
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, indentSize: 0)]),
|
||||
headerTitle: 'Roles',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class ListKeysCommand extends Command
|
||||
$messagePattern = $this->determineMessagePattern($apiKey);
|
||||
|
||||
// Set columns for this row
|
||||
$rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name ?? '-')];
|
||||
$rowData = [sprintf($messagePattern, $apiKey->name ?? '-')];
|
||||
if (! $enabledOnly) {
|
||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||
}
|
||||
@@ -67,7 +67,6 @@ class ListKeysCommand extends Command
|
||||
}, $this->apiKeyService->listKeys($enabledOnly));
|
||||
|
||||
ShlinkTable::withRowSeparators($output)->render(array_filter([
|
||||
'Key',
|
||||
'Name',
|
||||
! $enabledOnly ? 'Is enabled' : null,
|
||||
'Expiration date',
|
||||
|
||||
77
module/CLI/src/Command/Api/RenameApiKeyCommand.php
Normal file
77
module/CLI/src/Command/Api/RenameApiKeyCommand.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Model\Renaming;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
|
||||
class RenameApiKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:rename';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Renames an API key by name')
|
||||
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the API key to rename')
|
||||
->addArgument('newName', InputArgument::REQUIRED, 'New name to set to the API key');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$oldName = $input->getArgument('oldName');
|
||||
$newName = $input->getArgument('newName');
|
||||
|
||||
if ($oldName === null) {
|
||||
$apiKeys = $this->apiKeyService->listKeys();
|
||||
$requestedOldName = $io->choice(
|
||||
'What API key do you want to rename?',
|
||||
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
|
||||
);
|
||||
|
||||
$input->setArgument('oldName', $requestedOldName);
|
||||
}
|
||||
|
||||
if ($newName === null) {
|
||||
$requestedNewName = $io->ask(
|
||||
'What is the new name you want to set?',
|
||||
validator: static fn (string|null $value): string => $value !== null
|
||||
? $value
|
||||
: throw new InvalidArgumentException('The new name cannot be empty'),
|
||||
);
|
||||
|
||||
$input->setArgument('newName', $requestedNewName);
|
||||
}
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$oldName = $input->getArgument('oldName');
|
||||
$newName = $input->getArgument('newName');
|
||||
|
||||
$this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName));
|
||||
$io->success('API key properly renamed');
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class ReadEnvVarCommand extends Command
|
||||
/** @var Closure(string $envVar): mixed */
|
||||
private readonly Closure $loadEnvVar;
|
||||
|
||||
public function __construct(?Closure $loadEnvVar = null)
|
||||
public function __construct(Closure|null $loadEnvVar = null)
|
||||
{
|
||||
$this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv();
|
||||
parent::__construct();
|
||||
|
||||
@@ -74,7 +74,7 @@ class DomainRedirectsCommand extends Command
|
||||
$domainAuthority = $input->getArgument('domain');
|
||||
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||
|
||||
$ask = static function (string $message, ?string $current) use ($io): ?string {
|
||||
$ask = static function (string $message, string|null $current) use ($io): string|null {
|
||||
if ($current === null) {
|
||||
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class CreateShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:create';
|
||||
|
||||
private ?SymfonyStyle $io;
|
||||
private SymfonyStyle $io;
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -10,9 +10,10 @@ use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
||||
@@ -64,6 +65,12 @@ class ListShortUrlsCommand extends Command
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
@@ -111,14 +118,9 @@ class ListShortUrlsCommand extends Command
|
||||
'show-api-key',
|
||||
'k',
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key from which the URL was generated or not.',
|
||||
)
|
||||
->addOption(
|
||||
'show-api-key-name',
|
||||
'm',
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key name from which the URL was generated or not.',
|
||||
)
|
||||
->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key')
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
@@ -134,6 +136,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('search-term');
|
||||
$domain = $input->getOption('domain');
|
||||
$tags = $input->getOption('tags');
|
||||
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
@@ -145,6 +148,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
$data = [
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::DOMAIN => $domain,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
|
||||
@@ -177,7 +181,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
/**
|
||||
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
|
||||
* @return Paginator<ShortUrlWithVisitsSummary>
|
||||
* @return Paginator<ShortUrlWithDeps>
|
||||
*/
|
||||
private function renderPage(
|
||||
OutputInterface $output,
|
||||
@@ -187,7 +191,7 @@ class ListShortUrlsCommand extends Command
|
||||
): Paginator {
|
||||
$shortUrls = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
$rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) {
|
||||
$rows = map([...$shortUrls], function (ShortUrlWithDeps $shortUrl) use ($columnsMap) {
|
||||
$serializedShortUrl = $this->transformer->transform($shortUrl);
|
||||
return map($columnsMap, fn (callable $call) => $call($serializedShortUrl, $shortUrl->shortUrl));
|
||||
});
|
||||
@@ -201,7 +205,7 @@ class ListShortUrlsCommand extends Command
|
||||
return $shortUrls;
|
||||
}
|
||||
|
||||
private function processOrderBy(InputInterface $input): ?string
|
||||
private function processOrderBy(InputInterface $input): string|null
|
||||
{
|
||||
$orderBy = $input->getOption('order-by');
|
||||
if (empty($orderBy)) {
|
||||
@@ -231,14 +235,10 @@ class ListShortUrlsCommand extends Command
|
||||
}
|
||||
if ($input->getOption('show-domain')) {
|
||||
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
$shortUrl->getDomain()?->authority ?? 'DEFAULT';
|
||||
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
|
||||
}
|
||||
if ($input->getOption('show-api-key')) {
|
||||
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
$shortUrl->authorApiKey?->__toString() ?? '';
|
||||
}
|
||||
if ($input->getOption('show-api-key-name')) {
|
||||
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
|
||||
if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) {
|
||||
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
|
||||
$shortUrl->authorApiKey?->name;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Model\Renaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -40,7 +40,7 @@ class RenameTagCommand extends Command
|
||||
$newName = $input->getArgument('newName');
|
||||
|
||||
try {
|
||||
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
|
||||
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
|
||||
$io->success('Tag properly renamed.');
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (TagNotFoundException | TagConflictException $e) {
|
||||
|
||||
@@ -61,8 +61,8 @@ abstract class AbstractVisitsListCommand extends Command
|
||||
'date' => $visit->date->toAtomString(),
|
||||
'userAgent' => $visit->userAgent,
|
||||
'potentialBot' => $visit->potentialBot,
|
||||
'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown',
|
||||
'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown',
|
||||
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
|
||||
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
|
||||
...$extraFields,
|
||||
];
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
|
||||
private ?ProgressBar $progressBar = null;
|
||||
private ProgressBar|null $progressBar = null;
|
||||
|
||||
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
|
||||
@@ -13,12 +13,12 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
||||
{
|
||||
private bool $olderDbExists;
|
||||
|
||||
private function __construct(string $message, ?Throwable $previous = null)
|
||||
private function __construct(string $message, Throwable|null $previous = null)
|
||||
{
|
||||
parent::__construct($message, previous: $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(?Throwable $prev = null): self
|
||||
public static function withOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
@@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function withoutOlderDb(?Throwable $prev = null): self
|
||||
public static function withoutOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
|
||||
@@ -40,9 +40,11 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult
|
||||
{
|
||||
if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) {
|
||||
public function checkDbUpdate(
|
||||
callable|null $beforeDownload = null,
|
||||
callable|null $handleProgress = null,
|
||||
): GeolocationResult {
|
||||
if (! $this->trackingOptions->isGeolocationRelevant()) {
|
||||
return GeolocationResult::CHECK_SKIPPED;
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult
|
||||
private function downloadIfNeeded(callable|null $beforeDownload, callable|null $handleProgress): GeolocationResult
|
||||
{
|
||||
if (! $this->dbUpdater->databaseFileExists()) {
|
||||
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
|
||||
@@ -105,8 +107,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
*/
|
||||
private function downloadNewDb(
|
||||
bool $olderDbExists,
|
||||
?callable $beforeDownload,
|
||||
?callable $handleProgress,
|
||||
callable|null $beforeDownload,
|
||||
callable|null $handleProgress,
|
||||
): GeolocationResult {
|
||||
if ($beforeDownload !== null) {
|
||||
$beforeDownload($olderDbExists);
|
||||
@@ -124,7 +126,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
}
|
||||
}
|
||||
|
||||
private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable
|
||||
private function wrapHandleProgressCallback(callable|null $handleProgress, bool $olderDbExists): callable|null
|
||||
{
|
||||
if ($handleProgress === null) {
|
||||
return null;
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GeolocationDbUpdaterInterface
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(
|
||||
?callable $beforeDownload = null,
|
||||
?callable $handleProgress = null,
|
||||
callable|null $beforeDownload = null,
|
||||
callable|null $handleProgress = null,
|
||||
): GeolocationResult;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ readonly class DateOption
|
||||
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
public function get(InputInterface $input, OutputInterface $output): Chronos|null
|
||||
{
|
||||
$value = $input->getOption($this->name);
|
||||
if (empty($value) || ! is_string($value)) {
|
||||
|
||||
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
readonly final class EndDateOption
|
||||
final readonly class EndDateOption
|
||||
{
|
||||
private DateOption $dateOption;
|
||||
|
||||
@@ -23,7 +23,7 @@ readonly final class EndDateOption
|
||||
));
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
public function get(InputInterface $input, OutputInterface $output): Chronos|null
|
||||
{
|
||||
return $this->dateOption->get($input, $output);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use function array_unique;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
readonly final class ShortUrlDataInput
|
||||
final readonly class ShortUrlDataInput
|
||||
{
|
||||
public function __construct(Command $command, private bool $longUrlAsOption = false)
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ enum ShortUrlDataOption: string
|
||||
case CRAWLABLE = 'crawlable';
|
||||
case NO_FORWARD_QUERY = 'no-forward-query';
|
||||
|
||||
public function shortcut(): ?string
|
||||
public function shortcut(): string|null
|
||||
{
|
||||
return match ($this) {
|
||||
self::TAGS => 't',
|
||||
|
||||
@@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
readonly final class ShortUrlIdentifierInput
|
||||
final readonly class ShortUrlIdentifierInput
|
||||
{
|
||||
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
|
||||
{
|
||||
@@ -19,7 +19,7 @@ readonly final class ShortUrlIdentifierInput
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
|
||||
}
|
||||
|
||||
public function shortCode(InputInterface $input): ?string
|
||||
public function shortCode(InputInterface $input): string|null
|
||||
{
|
||||
return $input->getArgument('shortCode');
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
readonly final class StartDateOption
|
||||
final readonly class StartDateOption
|
||||
{
|
||||
private DateOption $dateOption;
|
||||
|
||||
@@ -23,7 +23,7 @@ readonly final class StartDateOption
|
||||
));
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
public function get(InputInterface $input, OutputInterface $output): Chronos|null
|
||||
{
|
||||
return $this->dateOption->get($input, $output);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ use const STR_PAD_LEFT;
|
||||
|
||||
class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
{
|
||||
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array
|
||||
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null
|
||||
{
|
||||
$amountOfRules = count($rules);
|
||||
|
||||
@@ -111,6 +111,12 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
|
||||
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
|
||||
),
|
||||
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode(
|
||||
$this->askMandatory('Country code to match?', $io),
|
||||
),
|
||||
RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName(
|
||||
$this->askMandatory('City name to match?', $io),
|
||||
)
|
||||
};
|
||||
|
||||
$continue = $io->confirm('Do you want to add another condition?');
|
||||
@@ -213,7 +219,7 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
|
||||
private function askMandatory(string $message, StyleInterface $io): string
|
||||
{
|
||||
return $io->ask($message, validator: function (?string $answer): string {
|
||||
return $io->ask($message, validator: function (string|null $answer): string {
|
||||
if ($answer === null) {
|
||||
throw new InvalidArgumentException('The value is mandatory');
|
||||
}
|
||||
@@ -223,6 +229,6 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
|
||||
private function askOptional(string $message, StyleInterface $io): string
|
||||
{
|
||||
return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer));
|
||||
return $io->ask($message, validator: fn (string|null $answer) => $answer === null ? '' : trim($answer));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@ interface RedirectRuleHandlerInterface
|
||||
* @param ShortUrlRedirectRule[] $rules
|
||||
* @return ShortUrlRedirectRule[]|null - A new list of rules to save, or null if no changes should be saved
|
||||
*/
|
||||
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array;
|
||||
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class ProcessRunner implements ProcessRunnerInterface
|
||||
{
|
||||
private Closure $createProcess;
|
||||
|
||||
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
|
||||
public function __construct(private ProcessHelper $helper, callable|null $createProcess = null)
|
||||
{
|
||||
$this->createProcess = $createProcess !== null
|
||||
? $createProcess(...)
|
||||
|
||||
@@ -34,8 +34,12 @@ final class ShlinkTable
|
||||
return new self($baseTable);
|
||||
}
|
||||
|
||||
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
|
||||
{
|
||||
public function render(
|
||||
array $headers,
|
||||
array $rows,
|
||||
string|null $footerTitle = null,
|
||||
string|null $headerTitle = null,
|
||||
): void {
|
||||
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
|
||||
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
|
||||
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
|
||||
|
||||
@@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class CreateShortUrlTest extends CliTestCase
|
||||
@@ -26,6 +27,6 @@ class CreateShortUrlTest extends CliTestCase
|
||||
self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output);
|
||||
|
||||
[$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]);
|
||||
self::assertStringContainsString('DEFAULT', $listOutput);
|
||||
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Importer\Command\ImportCommand;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
@@ -66,10 +67,10 @@ class ImportShortUrlsTest extends CliTestCase
|
||||
[$listOutput1] = $this->exec(
|
||||
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'],
|
||||
);
|
||||
self::assertStringContainsString('DEFAULT', $listOutput1);
|
||||
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1);
|
||||
[$listOutput1] = $this->exec(
|
||||
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'],
|
||||
);
|
||||
self::assertStringContainsString('DEFAULT', $listOutput1);
|
||||
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,38 +26,38 @@ class ListApiKeysTest extends CliTestCase
|
||||
{
|
||||
$expiredApiKeyDate = Chronos::now()->subDays(1)->startOfDay()->toAtomString();
|
||||
$enabledOnlyOutput = <<<OUT
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
| valid_api_key | - | - | Admin |
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
| expired_api_key | - | {$expiredApiKeyDate} | Admin |
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
| author_api_key | - | - | Author only |
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
| domain_api_key | - | - | Domain only: example.com |
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
| no_orphans_api_key | - | - | No orphan visits |
|
||||
+--------------------+------+---------------------------+--------------------------+
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
| Name | Expiration date | Roles |
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
| valid_api_key | - | Admin |
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
| expired_api_key | {$expiredApiKeyDate} | Admin |
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
| author_api_key | - | Author only |
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
| domain_api_key | - | Domain only: example.com |
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
| no_orphans_api_key | - | No orphan visits |
|
||||
+--------------------+---------------------------+--------------------------+
|
||||
|
||||
OUT;
|
||||
|
||||
yield 'no flags' => [[], <<<OUT
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| Key | Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| valid_api_key | - | +++ | - | Admin |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| disabled_api_key | - | --- | - | Admin |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| expired_api_key | - | --- | {$expiredApiKeyDate} | Admin |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| author_api_key | - | +++ | - | Author only |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| domain_api_key | - | +++ | - | Domain only: example.com |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
| no_orphans_api_key | - | +++ | - | No orphan visits |
|
||||
+--------------------+------+------------+---------------------------+--------------------------+
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| valid_api_key | +++ | - | Admin |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| disabled_api_key | --- | - | Admin |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| expired_api_key | --- | {$expiredApiKeyDate} | Admin |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| author_api_key | +++ | - | Author only |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| domain_api_key | +++ | - | Domain only: example.com |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
| no_orphans_api_key | +++ | - | No orphan visits |
|
||||
+--------------------+------------+---------------------------+--------------------------+
|
||||
|
||||
OUT];
|
||||
yield '-e' => [['-e'], $enabledOnlyOutput];
|
||||
|
||||
@@ -70,6 +70,23 @@ class ListShortUrlsTest extends CliTestCase
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
+--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'non-default domain' => [['--domain=example.com'], <<<OUTPUT
|
||||
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
+------------+-------+---------------------------+-------------------------------------------- Page 1 of 1 --------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'default domain' => [['-d DEFAULT'], <<<OUTPUT
|
||||
+------------+---------------+----------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+------------+---------------+----------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -28,30 +31,103 @@ class DisableKeyCommandTest extends TestCase
|
||||
public function providedApiKeyIsDisabled(): void
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey);
|
||||
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByName');
|
||||
|
||||
$this->commandTester->execute([
|
||||
'apiKey' => $apiKey,
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'keyOrName' => $apiKey,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfServiceThrowsException(): void
|
||||
public function providedApiKeyIsDisabledByName(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'keyOrName' => $name,
|
||||
'--by-name' => true,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfDisableByKeyThrowsException(): void
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$expectedMessage = 'API key "abcd1234" does not exist.';
|
||||
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException(
|
||||
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException(
|
||||
new InvalidArgumentException($expectedMessage),
|
||||
);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByName');
|
||||
|
||||
$this->commandTester->execute([
|
||||
'apiKey' => $apiKey,
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'keyOrName' => $apiKey,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfDisableByNameThrowsException(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$expectedMessage = 'API key "the key to delete" does not exist.';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException(
|
||||
new InvalidArgumentException($expectedMessage),
|
||||
);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'keyOrName' => $name,
|
||||
'--by-name' => true,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->never())->method('disableByName');
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
|
||||
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
|
||||
]);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
|
||||
$this->commandTester->setInputs([$name]);
|
||||
$exitCode = $this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
@@ -36,7 +37,7 @@ class GenerateKeyCommandTest extends TestCase
|
||||
public function noExpirationDateIsDefinedIfNotProvided(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->once())->method('create')->with(
|
||||
$this->callback(fn (ApiKeyMeta $meta) => $meta->name === null && $meta->expirationDate === null),
|
||||
$this->callback(fn (ApiKeyMeta $meta) => $meta->expirationDate === null),
|
||||
)->willReturn(ApiKey::create());
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
@@ -64,8 +65,10 @@ class GenerateKeyCommandTest extends TestCase
|
||||
$this->callback(fn (ApiKeyMeta $meta) => $meta->name === 'Alice'),
|
||||
)->willReturn(ApiKey::create());
|
||||
|
||||
$this->commandTester->execute([
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'--name' => 'Alice',
|
||||
]);
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,11 @@ class InitialApiKeyCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideParams')]
|
||||
public function initialKeyIsCreatedWithProvidedValue(?ApiKey $result, bool $verbose, string $expectedOutput): void
|
||||
{
|
||||
public function initialKeyIsCreatedWithProvidedValue(
|
||||
ApiKey|null $result,
|
||||
bool $verbose,
|
||||
string $expectedOutput,
|
||||
): void {
|
||||
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
|
||||
|
||||
$this->commandTester->execute(
|
||||
|
||||
@@ -52,15 +52,15 @@ class ListKeysCommandTest extends TestCase
|
||||
],
|
||||
false,
|
||||
<<<OUTPUT
|
||||
+--------------------------------------+------+------------+---------------------------+-------+
|
||||
| Key | Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------------------------+------+------------+---------------------------+-------+
|
||||
| {$apiKey1} | - | --- | - | Admin |
|
||||
+--------------------------------------+------+------------+---------------------------+-------+
|
||||
| {$apiKey2} | - | --- | 2020-01-01T00:00:00+00:00 | Admin |
|
||||
+--------------------------------------+------+------------+---------------------------+-------+
|
||||
| {$apiKey3} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+---------------------------+-------+
|
||||
+--------------------------------------+------------+---------------------------+-------+
|
||||
| Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------------------------+------------+---------------------------+-------+
|
||||
| {$apiKey1->name} | --- | - | Admin |
|
||||
+--------------------------------------+------------+---------------------------+-------+
|
||||
| {$apiKey2->name} | --- | 2020-01-01T00:00:00+00:00 | Admin |
|
||||
+--------------------------------------+------------+---------------------------+-------+
|
||||
| {$apiKey3->name} | +++ | - | Admin |
|
||||
+--------------------------------------+------------+---------------------------+-------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
@@ -68,13 +68,13 @@ class ListKeysCommandTest extends TestCase
|
||||
[$apiKey1 = ApiKey::create()->disable(), $apiKey2 = ApiKey::create()],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey2} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| Name | Expiration date | Roles |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| {$apiKey1->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| {$apiKey2->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
@@ -94,45 +94,45 @@ class ListKeysCommandTest extends TestCase
|
||||
],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey2} | - | - | Author only |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey3} | - | - | Domain only: example.com |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey5} | - | - | Author only |
|
||||
| | | | Domain only: example.com |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey6} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| Name | Expiration date | Roles |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| {$apiKey1->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| {$apiKey2->name} | - | Author only |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| {$apiKey3->name} | - | Domain only: example.com |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| {$apiKey4->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| {$apiKey5->name} | - | Author only |
|
||||
| | | Domain only: example.com |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
| {$apiKey6->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+--------------------------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
yield 'with names' => [
|
||||
[
|
||||
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')),
|
||||
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')),
|
||||
$apiKey3 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: '')),
|
||||
$apiKey4 = ApiKey::create(),
|
||||
],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey1} | Alice | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey2} | Alice and Bob | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey3} | | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| Name | Expiration date | Roles |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| Alice | - | Admin |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| Alice and Bob | - | Admin |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| {$apiKey3->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
| {$apiKey4->name} | - | Admin |
|
||||
+--------------------------------------+-----------------+-------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
|
||||
83
module/CLI/test/Command/Api/RenameApiKeyCommandTest.php
Normal file
83
module/CLI/test/Command/Api/RenameApiKeyCommandTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\RenameApiKeyCommand;
|
||||
use Shlinkio\Shlink\Core\Model\Renaming;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class RenameApiKeyCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = CliTestUtils::testerForCommand(new RenameApiKeyCommand($this->apiKeyService));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function oldNameIsRequestedIfNotProvided(): void
|
||||
{
|
||||
$oldName = 'old name';
|
||||
$newName = 'new name';
|
||||
|
||||
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $oldName)),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
|
||||
]);
|
||||
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
|
||||
Renaming::fromNames($oldName, $newName),
|
||||
);
|
||||
|
||||
$this->commandTester->setInputs([$oldName]);
|
||||
$this->commandTester->execute([
|
||||
'newName' => $newName,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function newNameIsRequestedIfNotProvided(): void
|
||||
{
|
||||
$oldName = 'old name';
|
||||
$newName = 'new name';
|
||||
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
|
||||
Renaming::fromNames($oldName, $newName),
|
||||
);
|
||||
|
||||
$this->commandTester->setInputs([$newName]);
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function apiIsRenamedWithProvidedNames(): void
|
||||
{
|
||||
$oldName = 'old name';
|
||||
$newName = 'new name';
|
||||
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
|
||||
Renaming::fromNames($oldName, $newName),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
'newName' => $newName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideDomains')]
|
||||
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
|
||||
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(Domain|null $domain): void
|
||||
{
|
||||
$domainAuthority = 'my-domain.com';
|
||||
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
|
||||
|
||||
@@ -40,7 +40,7 @@ class GetDomainVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$domain = 's.test';
|
||||
|
||||
@@ -104,7 +104,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideDomains')]
|
||||
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
|
||||
public function properlyProcessesProvidedDomain(array $input, string|null $expectedDomain): void
|
||||
{
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) {
|
||||
@@ -128,8 +128,10 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideFlags')]
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void
|
||||
{
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedFlags(
|
||||
array $options,
|
||||
bool|null $expectedCrawlable,
|
||||
): void {
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) {
|
||||
|
||||
@@ -93,7 +93,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
||||
#[Test]
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate(
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$shortCode = 'abc123';
|
||||
|
||||
@@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
||||
@@ -25,7 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function count;
|
||||
use function explode;
|
||||
|
||||
class ListShortUrlsCommandTest extends TestCase
|
||||
@@ -48,7 +47,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
// The paginator will return more than one page
|
||||
$data = [];
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
|
||||
$data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
|
||||
}
|
||||
|
||||
$this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters()
|
||||
@@ -70,11 +69,11 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
// The paginator will return more than one page
|
||||
$data = [];
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
|
||||
$data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i));
|
||||
}
|
||||
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
|
||||
ShortUrlsParams::emptyInstance(),
|
||||
ShortUrlsParams::empty(),
|
||||
)->willReturn(new Paginator(new ArrayAdapter($data)));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
@@ -105,105 +104,111 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
#[Test, DataProvider('provideOptionalFlags')]
|
||||
public function provideOptionalFlagsMakesNewColumnsToBeIncluded(
|
||||
array $input,
|
||||
array $expectedContents,
|
||||
array $notExpectedContents,
|
||||
ApiKey $apiKey,
|
||||
string $expectedOutput,
|
||||
ShortUrl $shortUrl,
|
||||
): void {
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
|
||||
ShortUrlsParams::emptyInstance(),
|
||||
ShortUrlsParams::empty(),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([
|
||||
ShortUrlWithVisitsSummary::fromShortUrl(
|
||||
ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
'longUrl' => 'https://foo.com',
|
||||
'tags' => ['foo', 'bar', 'baz'],
|
||||
'apiKey' => $apiKey,
|
||||
])),
|
||||
),
|
||||
ShortUrlWithDeps::fromShortUrl($shortUrl),
|
||||
])));
|
||||
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute($input);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
if (count($expectedContents) === 0 && count($notExpectedContents) === 0) {
|
||||
self::fail('No expectations were run');
|
||||
}
|
||||
|
||||
foreach ($expectedContents as $column) {
|
||||
self::assertStringContainsString($column, $output);
|
||||
}
|
||||
foreach ($notExpectedContents as $column) {
|
||||
self::assertStringNotContainsString($column, $output);
|
||||
}
|
||||
self::assertStringContainsString($expectedOutput, $output);
|
||||
}
|
||||
|
||||
public static function provideOptionalFlags(): iterable
|
||||
{
|
||||
$apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key'));
|
||||
$key = $apiKey->toString();
|
||||
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
'longUrl' => 'https://foo.com',
|
||||
'tags' => ['foo', 'bar', 'baz'],
|
||||
'apiKey' => ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')),
|
||||
]));
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
$created = $shortUrl->dateCreated()->toAtomString();
|
||||
|
||||
// phpcs:disable Generic.Files.LineLength
|
||||
yield 'tags only' => [
|
||||
['--show-tags' => true],
|
||||
['| Tags ', '| foo, bar, baz'],
|
||||
['| API Key ', '| API Key Name |', $key, '| my api key', '| Domain', '| DEFAULT'],
|
||||
$apiKey,
|
||||
<<<OUTPUT
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags |
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+
|
||||
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz |
|
||||
+------------+-------+-------------+-------------- Page 1 of 1 ------------------+--------------+---------------+
|
||||
OUTPUT,
|
||||
$shortUrl,
|
||||
];
|
||||
yield 'domain only' => [
|
||||
['--show-domain' => true],
|
||||
['| Domain', '| DEFAULT'],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key ', '| API Key Name |', $key, '| my api key'],
|
||||
$apiKey,
|
||||
<<<OUTPUT
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Domain |
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------+
|
||||
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | DEFAULT |
|
||||
+------------+-------+-------------+----------- Page 1 of 1 ---------------------+--------------+---------+
|
||||
OUTPUT,
|
||||
$shortUrl,
|
||||
];
|
||||
yield 'api key only' => [
|
||||
['--show-api-key' => true],
|
||||
['| API Key ', $key],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key', '| Domain', '| DEFAULT'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'api key name only' => [
|
||||
['--show-api-key-name' => true],
|
||||
['| API Key Name |', '| my api key'],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key ', $key],
|
||||
$apiKey,
|
||||
<<<OUTPUT
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count | API Key Name |
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+--------------+
|
||||
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | my api key |
|
||||
+------------+-------+-------------+------------- Page 1 of 1 -------------------+--------------+--------------+
|
||||
OUTPUT,
|
||||
$shortUrl,
|
||||
];
|
||||
yield 'tags and api key' => [
|
||||
['--show-tags' => true, '--show-api-key' => true],
|
||||
['| API Key ', '| Tags ', '| foo, bar, baz', $key],
|
||||
['| API Key Name |', '| my api key'],
|
||||
$apiKey,
|
||||
<<<OUTPUT
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags | API Key Name |
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+--------------+
|
||||
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz | my api key |
|
||||
+------------+-------+-------------+-----------------+--- Page 1 of 1 -----------+--------------+---------------+--------------+
|
||||
OUTPUT,
|
||||
$shortUrl,
|
||||
];
|
||||
yield 'tags and domain' => [
|
||||
['--show-tags' => true, '--show-domain' => true],
|
||||
['| Tags ', '| foo, bar, baz', '| Domain', '| DEFAULT'],
|
||||
['| API Key Name |', '| my api key'],
|
||||
$apiKey,
|
||||
<<<OUTPUT
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags | Domain |
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+
|
||||
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz | DEFAULT |
|
||||
+------------+-------+-------------+-----------------+- Page 1 of 1 -------------+--------------+---------------+---------+
|
||||
OUTPUT,
|
||||
$shortUrl,
|
||||
];
|
||||
yield 'all' => [
|
||||
['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true, '--show-api-key-name' => true],
|
||||
[
|
||||
'| API Key ',
|
||||
'| Tags ',
|
||||
'| API Key Name |',
|
||||
'| foo, bar, baz',
|
||||
$key,
|
||||
'| my api key',
|
||||
'| Domain',
|
||||
'| DEFAULT',
|
||||
],
|
||||
[],
|
||||
$apiKey,
|
||||
['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true],
|
||||
<<<OUTPUT
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count | Tags | Domain | API Key Name |
|
||||
+------------+-------+-------------+-----------------+---------------------------+--------------+---------------+---------+--------------+
|
||||
| {$shortCode} | | http:/{$shortCode} | https://foo.com | {$created} | 0 | foo, bar, baz | DEFAULT | my api key |
|
||||
+------------+-------+-------------+-----------------+-------- Page 1 of 1 ------+--------------+---------------+---------+--------------+
|
||||
OUTPUT,
|
||||
$shortUrl,
|
||||
];
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideArgs')]
|
||||
public function serviceIsInvokedWithProvidedArgs(
|
||||
array $commandArgs,
|
||||
?int $page,
|
||||
?string $searchTerm,
|
||||
int|null $page,
|
||||
string|null $searchTerm,
|
||||
array $tags,
|
||||
string $tagsMode,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
string|null $startDate = null,
|
||||
string|null $endDate = null,
|
||||
): void {
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
|
||||
'page' => $page,
|
||||
@@ -260,7 +265,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideOrderBy')]
|
||||
public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void
|
||||
public function orderByIsProperlyComputed(array $commandArgs, string|null $expectedOrderBy): void
|
||||
{
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
|
||||
'orderBy' => $expectedOrderBy,
|
||||
|
||||
@@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$tag = 'abc123';
|
||||
|
||||
@@ -9,8 +9,8 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\Renaming;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -32,7 +32,7 @@ class RenameTagCommandTest extends TestCase
|
||||
$oldName = 'foo';
|
||||
$newName = 'bar';
|
||||
$this->tagService->expects($this->once())->method('renameTag')->with(
|
||||
TagRenaming::fromNames($oldName, $newName),
|
||||
Renaming::fromNames($oldName, $newName),
|
||||
)->willThrowException(TagNotFoundException::fromTag('foo'));
|
||||
|
||||
$this->commandTester->execute([
|
||||
@@ -50,7 +50,7 @@ class RenameTagCommandTest extends TestCase
|
||||
$oldName = 'foo';
|
||||
$newName = 'bar';
|
||||
$this->tagService->expects($this->once())->method('renameTag')->with(
|
||||
TagRenaming::fromNames($oldName, $newName),
|
||||
Renaming::fromNames($oldName, $newName),
|
||||
)->willReturn(new Tag($newName));
|
||||
|
||||
$this->commandTester->execute([
|
||||
|
||||
@@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn(
|
||||
|
||||
@@ -37,7 +37,7 @@ class GetOrphanVisitsCommandTest extends TestCase
|
||||
#[TestWith([['--type' => OrphanVisitType::BASE_URL->value], true])]
|
||||
public function outputIsProperlyGenerated(array $args, bool $includesType): void
|
||||
{
|
||||
$visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate(
|
||||
$visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback(
|
||||
|
||||
@@ -63,8 +63,8 @@ class LocateVisitsCommandTest extends TestCase
|
||||
bool $expectWarningPrint,
|
||||
array $args,
|
||||
): void {
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4'));
|
||||
$location = VisitLocation::fromGeolocation(Location::empty());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
@@ -107,7 +107,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
#[Test, DataProvider('provideIgnoredAddresses')]
|
||||
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
@@ -134,7 +134,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
#[Test]
|
||||
public function errorWhileLocatingIpIsDisplayed(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
|
||||
@@ -15,7 +15,7 @@ use Throwable;
|
||||
class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
#[Test, DataProvider('providePrev')]
|
||||
public function withOlderDbBuildsException(?Throwable $prev): void
|
||||
public function withOlderDbBuildsException(Throwable|null $prev): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withOlderDb($prev);
|
||||
|
||||
@@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('providePrev')]
|
||||
public function withoutOlderDbBuildsException(?Throwable $prev): void
|
||||
public function withoutOlderDbBuildsException(Throwable|null $prev): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
|
||||
|
||||
|
||||
@@ -41,22 +41,24 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
#[Test]
|
||||
public function properResultIsReturnedWhenLicenseIsMissing(): void
|
||||
{
|
||||
$mustBeUpdated = fn () => self::assertTrue(true);
|
||||
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException(
|
||||
new MissingLicenseException(''),
|
||||
);
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
$result = $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
|
||||
$isCalled = false;
|
||||
$result = $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
|
||||
$isCalled = true;
|
||||
});
|
||||
|
||||
self::assertTrue($isCalled);
|
||||
self::assertEquals(GeolocationResult::LICENSE_MISSING, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
|
||||
{
|
||||
$mustBeUpdated = fn () => self::assertTrue(true);
|
||||
$prev = new DbUpdateException('');
|
||||
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
|
||||
@@ -65,14 +67,17 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
)->willThrowException($prev);
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
$isCalled = false;
|
||||
try {
|
||||
$this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
|
||||
$this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
|
||||
$isCalled = true;
|
||||
});
|
||||
self::fail();
|
||||
} catch (Throwable $e) {
|
||||
/** @var GeolocationDbUpdateFailedException $e */
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertFalse($e->olderDbExists());
|
||||
self::assertTrue($isCalled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +97,6 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
$this->geolocationDbUpdater()->checkDbUpdate();
|
||||
self::fail();
|
||||
} catch (Throwable $e) {
|
||||
/** @var GeolocationDbUpdateFailedException $e */
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertTrue($e->olderDbExists());
|
||||
@@ -180,7 +184,7 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)];
|
||||
}
|
||||
|
||||
private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater
|
||||
private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater
|
||||
{
|
||||
$locker = $this->createMock(Lock\LockFactory::class);
|
||||
$locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock);
|
||||
|
||||
@@ -56,7 +56,7 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
#[Test, DataProvider('provideExitActions')]
|
||||
public function commentIsDisplayedWhenRulesListIsEmpty(
|
||||
RedirectRuleHandlerAction $action,
|
||||
?array $expectedResult,
|
||||
array|null $expectedResult,
|
||||
): void {
|
||||
$this->io->expects($this->once())->method('choice')->willReturn($action->value);
|
||||
$this->io->expects($this->once())->method('newLine');
|
||||
@@ -117,6 +117,8 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
'Query param name?' => 'foo',
|
||||
'Query param value?' => 'bar',
|
||||
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
|
||||
'Country code to match?' => 'FR',
|
||||
'City name to match?' => 'Los angeles',
|
||||
default => '',
|
||||
},
|
||||
);
|
||||
@@ -165,6 +167,14 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
true,
|
||||
];
|
||||
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
|
||||
yield 'Geolocation country code' => [
|
||||
RedirectConditionType::GEOLOCATION_COUNTRY_CODE,
|
||||
[RedirectCondition::forGeolocationCountryCode('FR')],
|
||||
];
|
||||
yield 'Geolocation city name' => [
|
||||
RedirectConditionType::GEOLOCATION_CITY_NAME,
|
||||
[RedirectCondition::forGeolocationCityName('Los angeles')],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
|
||||
use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
@@ -50,6 +51,10 @@ return [
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Repository\ShortUrlRepository::class => [
|
||||
EntityRepositoryFactory::class,
|
||||
ShortUrl\Entity\ShortUrl::class,
|
||||
],
|
||||
ShortUrl\Repository\ShortUrlListRepository::class => [
|
||||
EntityRepositoryFactory::class,
|
||||
ShortUrl\Entity\ShortUrl::class,
|
||||
@@ -64,8 +69,10 @@ return [
|
||||
],
|
||||
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Tag\Repository\TagRepository::class => [EntityRepositoryFactory::class, Tag\Entity\Tag::class],
|
||||
|
||||
Domain\DomainService::class => ConfigAbstractFactory::class,
|
||||
Domain\Repository\DomainRepository::class => [EntityRepositoryFactory::class, Domain\Entity\Domain::class],
|
||||
|
||||
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\RequestTracker::class => ConfigAbstractFactory::class,
|
||||
@@ -96,6 +103,8 @@ return [
|
||||
|
||||
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
|
||||
Geolocation\Middleware\IpGeolocationMiddleware::class => ConfigAbstractFactory::class,
|
||||
|
||||
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
|
||||
|
||||
Crawling\CrawlingHelper::class => ConfigAbstractFactory::class,
|
||||
@@ -132,6 +141,7 @@ return [
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class,
|
||||
EventDispatcherInterface::class,
|
||||
ShortUrl\Repository\ShortUrlRepository::class,
|
||||
],
|
||||
Visit\VisitsTracker::class => [
|
||||
'em',
|
||||
@@ -153,20 +163,30 @@ return [
|
||||
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
|
||||
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
|
||||
Visit\VisitsStatsHelper::class => ['em'],
|
||||
Tag\TagService::class => ['em'],
|
||||
Tag\TagService::class => ['em', Tag\Repository\TagRepository::class],
|
||||
ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
Config\Options\DeleteShortUrlsOptions::class,
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
ShortUrl\Repository\ExpiredShortUrlsRepository::class,
|
||||
],
|
||||
ShortUrl\ShortUrlResolver::class => ['em', Config\Options\UrlShortenerOptions::class],
|
||||
ShortUrl\ShortUrlResolver::class => [
|
||||
ShortUrl\Repository\ShortUrlRepository::class,
|
||||
Config\Options\UrlShortenerOptions::class,
|
||||
],
|
||||
ShortUrl\ShortUrlVisitsDeleter::class => [
|
||||
Visit\Repository\VisitDeleterRepository::class,
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
|
||||
Domain\DomainService::class => ['em', Config\Options\UrlShortenerOptions::class],
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => [
|
||||
ShortUrl\Repository\ShortUrlRepository::class,
|
||||
Config\Options\UrlShortenerOptions::class,
|
||||
],
|
||||
Domain\DomainService::class => [
|
||||
'em',
|
||||
Config\Options\UrlShortenerOptions::class,
|
||||
Domain\Repository\DomainRepository::class,
|
||||
],
|
||||
|
||||
Util\DoctrineBatchHelper::class => ['em'],
|
||||
Util\RedirectResponseHelper::class => [Config\Options\RedirectOptions::class],
|
||||
@@ -220,6 +240,13 @@ return [
|
||||
|
||||
EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class],
|
||||
|
||||
Geolocation\Middleware\IpGeolocationMiddleware::class => [
|
||||
IpLocationResolverInterface::class,
|
||||
DbUpdater::class,
|
||||
'Logger_Shlink',
|
||||
Config\Options\TrackingOptions::class,
|
||||
],
|
||||
|
||||
Importer\ImportedLinksProcessor::class => [
|
||||
'em',
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
|
||||
@@ -110,4 +110,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->columnName('forward_query')
|
||||
->option('default', true)
|
||||
->build();
|
||||
|
||||
$builder->createOneToMany('redirectRules', RedirectRule\Entity\ShortUrlRedirectRule::class)
|
||||
->mappedBy('shortUrl')
|
||||
->fetchExtraLazy()
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -75,4 +75,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->columnName('potential_bot')
|
||||
->option('default', false)
|
||||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('redirectUrl', Types::STRING), $emConfig)
|
||||
->columnName('redirect_url')
|
||||
->length(Visitor::REDIRECT_URL_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -15,23 +15,18 @@ use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
|
||||
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
|
||||
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper;
|
||||
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Config\runningInRoadRunner;
|
||||
|
||||
return (static function (): array {
|
||||
$regularEvents = [
|
||||
EventDispatcher\Event\UrlVisited::class => [
|
||||
EventDispatcher\LocateVisit::class,
|
||||
],
|
||||
EventDispatcher\Event\GeoLiteDbCreated::class => [
|
||||
EventDispatcher\LocateUnlocatedVisits::class,
|
||||
],
|
||||
];
|
||||
$asyncEvents = [
|
||||
EventDispatcher\Event\VisitLocated::class => [
|
||||
EventDispatcher\Event\UrlVisited::class => [
|
||||
EventDispatcher\Mercure\NotifyVisitToMercure::class,
|
||||
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
|
||||
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
|
||||
@@ -46,9 +41,9 @@ return (static function (): array {
|
||||
|
||||
// Send visits to matomo asynchronously if the runtime allows it
|
||||
if (runningInRoadRunner()) {
|
||||
$asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class;
|
||||
$asyncEvents[EventDispatcher\Event\UrlVisited::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class;
|
||||
} else {
|
||||
$regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class];
|
||||
$regularEvents[EventDispatcher\Event\UrlVisited::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class];
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -60,7 +55,6 @@ return (static function (): array {
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
|
||||
@@ -104,13 +98,6 @@ return (static function (): array {
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
EventDispatcher\LocateVisit::class => [
|
||||
IpLocationResolverInterface::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
DbUpdater::class,
|
||||
EventDispatcherInterface::class,
|
||||
],
|
||||
EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class],
|
||||
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
|
||||
MercureHubPublishingHelper::class,
|
||||
|
||||
@@ -15,9 +15,9 @@ use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
use function array_keys;
|
||||
use function array_map;
|
||||
@@ -37,6 +37,8 @@ use function strtolower;
|
||||
use function trim;
|
||||
use function ucfirst;
|
||||
|
||||
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
|
||||
|
||||
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
|
||||
{
|
||||
static $nanoIdClient;
|
||||
@@ -50,7 +52,7 @@ function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode:
|
||||
return $nanoIdClient->formattedId($alphabet, $length);
|
||||
}
|
||||
|
||||
function parseDateFromQuery(array $query, string $dateName): ?Chronos
|
||||
function parseDateFromQuery(array $query, string $dateName): Chronos|null
|
||||
{
|
||||
return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
|
||||
}
|
||||
@@ -63,7 +65,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
|
||||
return buildDateRange($startDate, $endDate);
|
||||
}
|
||||
|
||||
function dateRangeToHumanFriendly(?DateRange $dateRange): string
|
||||
function dateRangeToHumanFriendly(DateRange|null $dateRange): string
|
||||
{
|
||||
$startDate = $dateRange?->startDate;
|
||||
$endDate = $dateRange?->endDate;
|
||||
@@ -83,7 +85,7 @@ function dateRangeToHumanFriendly(?DateRange $dateRange): string
|
||||
/**
|
||||
* @return ($date is null ? null : Chronos)
|
||||
*/
|
||||
function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
|
||||
function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): Chronos|null
|
||||
{
|
||||
$parsedDate = match (true) {
|
||||
$date === null || $date instanceof Chronos => $date,
|
||||
@@ -109,7 +111,7 @@ function normalizeLocale(string $locale): string
|
||||
* minimum quality
|
||||
*
|
||||
* @param non-empty-string $acceptLanguage
|
||||
* @return iterable<string>;
|
||||
* @return iterable<string>
|
||||
*/
|
||||
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
|
||||
{
|
||||
@@ -148,7 +150,7 @@ function splitLocale(string $locale): array
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): int|null
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
@@ -157,7 +159,7 @@ function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldNa
|
||||
/**
|
||||
* @param InputFilter<mixed> $inputFilter
|
||||
*/
|
||||
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
|
||||
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): bool|null
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (bool) $value : null;
|
||||
@@ -276,7 +278,7 @@ function enumToString(string $enum): string
|
||||
* Split provided string by comma and return a list of the results.
|
||||
* An empty array is returned if provided value is empty
|
||||
*/
|
||||
function splitByComma(?string $value): array
|
||||
function splitByComma(string|null $value): array
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return [];
|
||||
@@ -285,7 +287,17 @@ function splitByComma(?string $value): array
|
||||
return array_map(trim(...), explode(',', $value));
|
||||
}
|
||||
|
||||
function ipAddressFromRequest(ServerRequestInterface $request): ?string
|
||||
function ipAddressFromRequest(ServerRequestInterface $request): string|null
|
||||
{
|
||||
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
||||
return $request->getAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE);
|
||||
}
|
||||
|
||||
function geolocationFromRequest(ServerRequestInterface $request): Location|null
|
||||
{
|
||||
$geolocation = $request->getAttribute(Location::class);
|
||||
if ($geolocation !== null && ! $geolocation instanceof Location) {
|
||||
// TODO Throw exception
|
||||
}
|
||||
|
||||
return $geolocation;
|
||||
}
|
||||
|
||||
40
module/Core/migrations/Version20241105094747.php
Normal file
40
module/Core/migrations/Version20241105094747.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* In preparation to start hashing API keys, move all plain-text keys to the `name` column for all keys without name,
|
||||
* and append it to the name for all keys which already have a name.
|
||||
*/
|
||||
final class Version20241105094747 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$keyColumnName = $this->connection->quoteIdentifier('key');
|
||||
|
||||
// Append key to the name for all API keys that already have a name
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->update('api_keys')
|
||||
->set('name', 'CONCAT(name, ' . $this->connection->quote(' - ') . ', ' . $keyColumnName . ')')
|
||||
->where($qb->expr()->isNotNull('name'));
|
||||
$qb->executeStatement();
|
||||
|
||||
// Set plain key as name for all API keys without a name
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->update('api_keys')
|
||||
->set('name', $keyColumnName)
|
||||
->where($qb->expr()->isNull('name'));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
||||
45
module/Core/migrations/Version20241105215309.php
Normal file
45
module/Core/migrations/Version20241105215309.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
use function hash;
|
||||
|
||||
/**
|
||||
* Hash API keys as SHA256
|
||||
*/
|
||||
final class Version20241105215309 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$keyColumnName = $this->connection->quoteIdentifier('key');
|
||||
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->select($keyColumnName)
|
||||
->from('api_keys');
|
||||
$result = $qb->executeQuery();
|
||||
|
||||
$updateQb = $this->connection->createQueryBuilder();
|
||||
$updateQb
|
||||
->update('api_keys')
|
||||
->set($keyColumnName, ':encryptedKey')
|
||||
->where($updateQb->expr()->eq($keyColumnName, ':plainTextKey'));
|
||||
|
||||
while ($key = $result->fetchOne()) {
|
||||
$updateQb->setParameters([
|
||||
'encryptedKey' => hash('sha256', $key),
|
||||
'plainTextKey' => $key,
|
||||
])->executeStatement();
|
||||
}
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
||||
39
module/Core/migrations/Version20241124112257.php
Normal file
39
module/Core/migrations/Version20241124112257.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20241124112257 extends AbstractMigration
|
||||
{
|
||||
private const COLUMN_NAME = 'redirect_url';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasColumn(self::COLUMN_NAME));
|
||||
|
||||
$visits->addColumn(self::COLUMN_NAME, Types::STRING, [
|
||||
'length' => 2048,
|
||||
'notnull' => false,
|
||||
'default' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf(! $visits->hasColumn(self::COLUMN_NAME));
|
||||
$visits->dropColumn(self::COLUMN_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
|
||||
|
||||
use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE;
|
||||
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
|
||||
{
|
||||
public function __construct(
|
||||
@@ -30,9 +32,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
$this->requestTracker->trackIfApplicable($shortUrl, $request);
|
||||
$response = $this->createSuccessResp($shortUrl, $request);
|
||||
$this->requestTracker->trackIfApplicable($shortUrl, $request->withAttribute(
|
||||
REDIRECT_URL_REQUEST_ATTRIBUTE,
|
||||
$response->hasHeader('Location') ? $response->getHeaderLine('Location') : null,
|
||||
));
|
||||
|
||||
return $this->createSuccessResp($shortUrl, $request);
|
||||
return $response;
|
||||
} catch (ShortUrlNotFoundException) {
|
||||
return $this->createErrorResp($request, $handler);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ final class QrCodeParams
|
||||
return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR);
|
||||
}
|
||||
|
||||
private static function parseHexColor(string $hexColor, ?string $fallback): Color
|
||||
private static function parseHexColor(string $hexColor, string|null $fallback): Color
|
||||
{
|
||||
$hexColor = ltrim($hexColor, '#');
|
||||
if (! ctype_xdigit($hexColor) && $fallback !== null) {
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
@@ -13,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
|
||||
|
||||
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
|
||||
class RedirectAction extends AbstractTrackingAction
|
||||
{
|
||||
public function __construct(
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function invalidShortUrlRedirect(): ?string
|
||||
public function invalidShortUrlRedirect(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa
|
||||
return false;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): ?string
|
||||
public function regular404Redirect(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa
|
||||
return false;
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): ?string
|
||||
public function baseUrlRedirect(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@ namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
interface NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function invalidShortUrlRedirect(): ?string;
|
||||
public function invalidShortUrlRedirect(): string|null;
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool;
|
||||
|
||||
public function regular404Redirect(): ?string;
|
||||
public function regular404Redirect(): string|null;
|
||||
|
||||
public function hasRegular404Redirect(): bool;
|
||||
|
||||
public function baseUrlRedirect(): ?string;
|
||||
public function baseUrlRedirect(): string|null;
|
||||
|
||||
public function hasBaseUrlRedirect(): bool;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||
NotFoundType $notFoundType,
|
||||
NotFoundRedirectConfigInterface $config,
|
||||
UriInterface $currentUri,
|
||||
): ?ResponseInterface {
|
||||
): ResponseInterface|null {
|
||||
$urlToRedirectTo = match (true) {
|
||||
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(),
|
||||
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(),
|
||||
|
||||
@@ -14,5 +14,5 @@ interface NotFoundRedirectResolverInterface
|
||||
NotFoundType $notFoundType,
|
||||
NotFoundRedirectConfigInterface $config,
|
||||
UriInterface $currentUri,
|
||||
): ?ResponseInterface;
|
||||
): ResponseInterface|null;
|
||||
}
|
||||
|
||||
@@ -9,16 +9,16 @@ use JsonSerializable;
|
||||
final class NotFoundRedirects implements JsonSerializable
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?string $baseUrlRedirect,
|
||||
public readonly ?string $regular404Redirect,
|
||||
public readonly ?string $invalidShortUrlRedirect,
|
||||
public readonly string|null $baseUrlRedirect,
|
||||
public readonly string|null $regular404Redirect,
|
||||
public readonly string|null $invalidShortUrlRedirect,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function withRedirects(
|
||||
?string $baseUrlRedirect,
|
||||
?string $regular404Redirect = null,
|
||||
?string $invalidShortUrlRedirect = null,
|
||||
string|null $baseUrlRedirect,
|
||||
string|null $regular404Redirect = null,
|
||||
string|null $invalidShortUrlRedirect = null,
|
||||
): self {
|
||||
return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $invalidShortUrl = null,
|
||||
public ?string $regular404 = null,
|
||||
public ?string $baseUrl = null,
|
||||
public string|null $invalidShortUrl = null,
|
||||
public string|null $regular404 = null,
|
||||
public string|null $baseUrl = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): ?string
|
||||
public function invalidShortUrlRedirect(): string|null
|
||||
{
|
||||
return $this->invalidShortUrl;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn
|
||||
return $this->invalidShortUrl !== null;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): ?string
|
||||
public function regular404Redirect(): string|null
|
||||
{
|
||||
return $this->regular404;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn
|
||||
return $this->regular404 !== null;
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): ?string
|
||||
public function baseUrlRedirect(): string|null
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ final readonly class QrCodeOptions
|
||||
public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
|
||||
public string $color = DEFAULT_QR_CODE_COLOR,
|
||||
public string $bgColor = DEFAULT_QR_CODE_BG_COLOR,
|
||||
public ?string $logoUrl = null,
|
||||
public string|null $logoUrl = null,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ final readonly class TrackingOptions
|
||||
public bool $trackOrphanVisits = true,
|
||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence over
|
||||
// other options
|
||||
public ?string $disableTrackParam = null,
|
||||
public string|null $disableTrackParam = null,
|
||||
// If true, visits will not be tracked at all
|
||||
public bool $disableTracking = false,
|
||||
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
||||
@@ -59,4 +59,12 @@ final readonly class TrackingOptions
|
||||
{
|
||||
return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* If IP address tracking is disabled, or tracking is disabled all together, then geolocation is not relevant
|
||||
*/
|
||||
public function isGeolocationRelevant(): bool
|
||||
{
|
||||
return ! $this->disableTracking && ! $this->disableIpTracking;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Crawling;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface;
|
||||
|
||||
class CrawlingHelper implements CrawlingHelperInterface
|
||||
readonly class CrawlingHelper implements CrawlingHelperInterface
|
||||
{
|
||||
public function __construct(private readonly CrawlableShortCodesQueryInterface $query)
|
||||
public function __construct(private CrawlableShortCodesQueryInterface $query)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -19,14 +19,17 @@ use function array_map;
|
||||
|
||||
readonly class DomainService implements DomainServiceInterface
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em, private UrlShortenerOptions $urlShortenerOptions)
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private UrlShortenerOptions $urlShortenerOptions,
|
||||
private DomainRepositoryInterface $repo,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DomainItem[]
|
||||
*/
|
||||
public function listDomains(?ApiKey $apiKey = null): array
|
||||
public function listDomains(ApiKey|null $apiKey = null): array
|
||||
{
|
||||
[$default, $domains] = $this->defaultDomainAndRest($apiKey);
|
||||
$mappedDomains = array_map(fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain), $domains);
|
||||
@@ -47,11 +50,9 @@ readonly class DomainService implements DomainServiceInterface
|
||||
/**
|
||||
* @return array{Domain|null, Domain[]}
|
||||
*/
|
||||
private function defaultDomainAndRest(?ApiKey $apiKey): array
|
||||
private function defaultDomainAndRest(ApiKey|null $apiKey): array
|
||||
{
|
||||
/** @var DomainRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Domain::class);
|
||||
$allDomains = $repo->findDomains($apiKey);
|
||||
$allDomains = $this->repo->findDomains($apiKey);
|
||||
$defaultDomain = null;
|
||||
$restOfDomains = [];
|
||||
|
||||
@@ -71,7 +72,6 @@ readonly class DomainService implements DomainServiceInterface
|
||||
*/
|
||||
public function getDomain(string $domainId): Domain
|
||||
{
|
||||
/** @var Domain|null $domain */
|
||||
$domain = $this->em->find(Domain::class, $domainId);
|
||||
if ($domain === null) {
|
||||
throw DomainNotFoundException::fromId($domainId);
|
||||
@@ -80,15 +80,15 @@ readonly class DomainService implements DomainServiceInterface
|
||||
return $domain;
|
||||
}
|
||||
|
||||
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
|
||||
public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null
|
||||
{
|
||||
return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey);
|
||||
return $this->repo->findOneByAuthority($authority, $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DomainNotFoundException
|
||||
*/
|
||||
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain
|
||||
public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain
|
||||
{
|
||||
$domain = $this->getPersistedDomain($authority, $apiKey);
|
||||
$this->em->flush();
|
||||
@@ -102,7 +102,7 @@ readonly class DomainService implements DomainServiceInterface
|
||||
public function configureNotFoundRedirects(
|
||||
string $authority,
|
||||
NotFoundRedirects $notFoundRedirects,
|
||||
?ApiKey $apiKey = null,
|
||||
ApiKey|null $apiKey = null,
|
||||
): Domain {
|
||||
$domain = $this->getPersistedDomain($authority, $apiKey);
|
||||
$domain->configureNotFoundRedirects($notFoundRedirects);
|
||||
@@ -115,7 +115,7 @@ readonly class DomainService implements DomainServiceInterface
|
||||
/**
|
||||
* @throws DomainNotFoundException
|
||||
*/
|
||||
private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain
|
||||
private function getPersistedDomain(string $authority, ApiKey|null $apiKey): Domain
|
||||
{
|
||||
$domain = $this->findByAuthority($authority, $apiKey);
|
||||
if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface DomainServiceInterface
|
||||
/**
|
||||
* @return DomainItem[]
|
||||
*/
|
||||
public function listDomains(?ApiKey $apiKey = null): array;
|
||||
public function listDomains(ApiKey|null $apiKey = null): array;
|
||||
|
||||
/**
|
||||
* @throws DomainNotFoundException
|
||||
@@ -25,9 +25,9 @@ interface DomainServiceInterface
|
||||
/**
|
||||
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
|
||||
*/
|
||||
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain;
|
||||
public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain;
|
||||
|
||||
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
|
||||
public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null;
|
||||
|
||||
/**
|
||||
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
|
||||
@@ -35,6 +35,6 @@ interface DomainServiceInterface
|
||||
public function configureNotFoundRedirects(
|
||||
string $authority,
|
||||
NotFoundRedirects $notFoundRedirects,
|
||||
?ApiKey $apiKey = null,
|
||||
ApiKey|null $apiKey = null,
|
||||
): Domain;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
|
||||
class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface
|
||||
{
|
||||
public const DEFAULT_AUTHORITY = 'DEFAULT';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $authority,
|
||||
private ?string $baseUrlRedirect = null,
|
||||
private ?string $regular404Redirect = null,
|
||||
private ?string $invalidShortUrlRedirect = null,
|
||||
private string|null $baseUrlRedirect = null,
|
||||
private string|null $regular404Redirect = null,
|
||||
private string|null $invalidShortUrlRedirect = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -29,7 +31,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
||||
return $this->authority;
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): ?string
|
||||
public function invalidShortUrlRedirect(): string|null
|
||||
{
|
||||
return $this->invalidShortUrlRedirect;
|
||||
}
|
||||
@@ -39,7 +41,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
||||
return $this->invalidShortUrlRedirect !== null;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): ?string
|
||||
public function regular404Redirect(): string|null
|
||||
{
|
||||
return $this->regular404Redirect;
|
||||
}
|
||||
@@ -49,7 +51,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
||||
return $this->regular404Redirect !== null;
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): ?string
|
||||
public function baseUrlRedirect(): string|null
|
||||
{
|
||||
return $this->baseUrlRedirect;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
|
||||
final class DomainItem implements JsonSerializable
|
||||
final readonly class DomainItem implements JsonSerializable
|
||||
{
|
||||
private function __construct(
|
||||
private readonly string $authority,
|
||||
public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig,
|
||||
public readonly bool $isDefault,
|
||||
private string $authority,
|
||||
public NotFoundRedirectConfigInterface $notFoundRedirectConfig,
|
||||
public bool $isDefault,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomains(?ApiKey $apiKey = null): array
|
||||
public function findDomains(ApiKey|null $apiKey = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('d');
|
||||
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
||||
@@ -39,7 +39,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
|
||||
public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null
|
||||
{
|
||||
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
|
||||
$qb->select('d');
|
||||
@@ -47,7 +47,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool
|
||||
public function domainExists(string $authority, ApiKey|null $apiKey = null): bool
|
||||
{
|
||||
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
|
||||
$qb->select('COUNT(d.id)');
|
||||
@@ -55,7 +55,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
|
||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||
}
|
||||
|
||||
private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder
|
||||
private function createDomainQueryBuilder(string $authority, ApiKey|null $apiKey): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Domain::class, 'd')
|
||||
@@ -72,7 +72,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function determineExtraSpecs(?ApiKey $apiKey): iterable
|
||||
private function determineExtraSpecs(ApiKey|null $apiKey): iterable
|
||||
{
|
||||
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
|
||||
// ShortUrl is the root entity. Here, the Domain is the root entity.
|
||||
|
||||
@@ -15,9 +15,9 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomains(?ApiKey $apiKey = null): array;
|
||||
public function findDomains(ApiKey|null $apiKey = null): array;
|
||||
|
||||
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
|
||||
public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null;
|
||||
|
||||
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool;
|
||||
public function domainExists(string $authority, ApiKey|null $apiKey = null): bool;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
|
||||
class IsDomain extends BaseSpecification
|
||||
{
|
||||
public function __construct(private string $domainId, ?string $context = null)
|
||||
public function __construct(private string $domainId, string|null $context = null)
|
||||
{
|
||||
parent::__construct($context);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType;
|
||||
|
||||
use function rtrim;
|
||||
|
||||
class NotFoundType
|
||||
readonly class NotFoundType
|
||||
{
|
||||
private function __construct(private readonly ?VisitType $type)
|
||||
private function __construct(private VisitType|null $type)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ readonly class NotFoundRedirectHandler implements MiddlewareInterface
|
||||
private function resolveDomainSpecificRedirect(
|
||||
UriInterface $currentUri,
|
||||
NotFoundType $notFoundType,
|
||||
): ?ResponseInterface {
|
||||
): ResponseInterface|null {
|
||||
$domain = $this->domainService->findByAuthority($currentUri->getAuthority());
|
||||
if ($domain === null) {
|
||||
return null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user