Compare commits

...

33 Commits

Author SHA1 Message Date
Alejandro Celaya
8efda2ef56 Merge pull request #1108 from kanadaj/develop
Change the Docker user to non-root
2021-07-15 20:19:42 +02:00
Alejandro Celaya
f86cda6730 Removed deprecated env var for publish release 2021-07-15 19:53:42 +02:00
Alejandro Celaya
43f59a19fb Merge pull request #1120 from acelaya-forks/feature/redirect-with-extra-path
Feature/redirect with extra path
2021-07-15 19:48:16 +02:00
Alejandro Celaya
eabaa94e06 Created ExtraPathRedirectMiddleware test 2021-07-15 19:37:09 +02:00
Alejandro Celaya
20575a2b0f Added support to provide append_extra_path config from installer or env vars for docker 2021-07-15 18:57:32 +02:00
Alejandro Celaya
0096a778ac Created RequestTracker test 2021-07-15 17:43:29 +02:00
Alejandro Celaya
050f83e3bb Wrapped logic to track requests to a new RequestTracker service 2021-07-15 17:23:09 +02:00
Alejandro Celaya
32f7b4fbf6 Created new middleware that redirects to short URLs with an extra path 2021-07-15 16:54:54 +02:00
Alejandro Celaya
265e8cdeaf Refactored tracking actions 2021-07-15 13:28:31 +02:00
Alejandro Celaya
fe5460e0c5 Created ShortUrlRedirectBuilder test 2021-07-14 16:44:21 +02:00
Alejandro Celaya
d4cad337fc Created component wrapping the logic to determine what's the URL to redirect to for a ShortUrl 2021-07-14 16:36:03 +02:00
Alejandro Celaya
0af6ecbd34 Merge pull request #1115 from acelaya-forks/feature/qr-code-correction
Feature/qr code correction
2021-07-13 14:13:34 +02:00
Alejandro Celaya
6466045363 Updated changelog 2021-07-13 14:00:54 +02:00
Alejandro Celaya
67c7e503d9 Used lowercase values when trying to match the QR code error level 2021-07-13 13:55:00 +02:00
Alejandro Celaya
01e06f0503 Improved swagger docs for QR code endpoint 2021-07-13 13:53:10 +02:00
Alejandro Celaya
d6e155d874 Extracted logic to determine QR code params to its own data object 2021-07-13 13:46:01 +02:00
Alejandro Celaya
5a2350bac1 Added suport for error correction level to QR codes 2021-07-13 13:22:50 +02:00
kanadaj
2b97f9ac9e Update Dockerfile
Security update
2021-06-13 23:54:35 +01:00
kanadaj
090b215179 Update Dockerfile 2021-06-13 23:51:16 +01:00
Alejandro Celaya
32f483c333 Merge pull request #1107 from PxSonny/patch-1
Update CONTRIBUTING.md
2021-06-13 21:21:44 +02:00
Sonny Alves Dias
655652f94f Update CONTRIBUTING.md
Fixing a typo
2021-06-13 22:24:20 +08:00
Alejandro Celaya
53b84c147c Merge branch 'develop' of github.com:shlinkio/shlink into develop 2021-05-30 17:55:37 +02:00
Alejandro Celaya
d8b4827601 Updated changelog 2021-05-30 17:55:30 +02:00
Alejandro Celaya
5737acf759 Merge pull request #1099 from mikafouenski/develop
Run periodic `visit:locate` as opt-in
2021-05-30 17:55:13 +02:00
Alejandro Celaya
58262e8604 Update docker/docker-entrypoint.sh 2021-05-30 17:41:40 +02:00
Alejandro Celaya
b9e5eaf689 Update docker/docker-entrypoint.sh 2021-05-30 17:41:00 +02:00
Alejandro Celaya
6d78cd59e9 Fixed merge conflicts 2021-05-30 13:31:37 +02:00
Mickaël Bernardini
bfdece1c23 add ENABLE_PERIODIC_VISIT_LOCATE opt-in
This will trigger `visit:locate` every hour
2021-05-26 15:45:24 +02:00
Alejandro Celaya
a68f450d36 Merge pull request #1097 from acelaya-forks/feature/php8
Feature/php8
2021-05-23 12:54:12 +02:00
Alejandro Celaya
d1df225e47 Moved changelog line 2021-05-23 12:39:00 +02:00
Alejandro Celaya
9c6ba4bc61 More PHP 8 syntactic sugar 2021-05-23 12:37:53 +02:00
Alejandro Celaya
c01121d61a Added nullsafe operator to simplify conditions 2021-05-23 12:31:10 +02:00
Alejandro Celaya
e0f0bb5523 Migrated all constructor props to property promotion when possible 2021-05-23 11:57:31 +02:00
194 changed files with 1166 additions and 1102 deletions

View File

@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -63,7 +63,7 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-unit
path: |
@@ -74,7 +74,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -89,7 +89,7 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-db
path: |
@@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -120,7 +120,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -140,7 +140,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -160,7 +160,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -184,7 +184,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -201,7 +201,7 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-api
path: |
@@ -216,7 +216,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
test-group: ['unit', 'db']
steps:
- name: Checkout code

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
swoole: ['yes', 'no']
steps:
- name: Checkout code
@@ -43,7 +43,6 @@ jobs:
uses: docker://antonyurchenko/git-release:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOW_TAG_PREFIX: "true"
ALLOW_EMPTY_CHANGELOG: "true"
with:
args: |
@@ -54,7 +53,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: [ '7.4', '8.0' ]
php-version: [ '8.0' ]
swoole: [ 'yes', 'no' ]
steps:
- uses: geekyeggo/delete-artifact@v1

View File

@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
### Added
* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`.
* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes.
Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High.
* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL.
With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`.
This behavior needs to be actively opted in, via installer config options or env vars.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
### Fixed
* *Nothing*
## [2.7.1] - 2021-05-30
### Added
* *Nothing*

View File

@@ -121,7 +121,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
For example, `test:db:postgres`.
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.

View File

@@ -78,4 +78,13 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
RUN chown 1001 /etc/shlink/data
RUN chown 1001 /etc/shlink/data/locks
RUN chown 1001 /etc/shlink/data/proxies
RUN chown 1001 /etc/shlink/data/cache
RUN chown 1001 /etc/shlink/data/log
USER 1001
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View File

@@ -33,7 +33,7 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 7.4 or 8.0
* PHP 8.0
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
* apcu extension is recommended if you don't plan to use swoole.
* xml extension is required if you want to generate QR codes in svg format.

View File

@@ -12,7 +12,7 @@
}
],
"require": {
"php": "^7.4 || ^8.0",
"php": "^8.0",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.0",
@@ -51,7 +51,7 @@
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3",
"shlinkio/shlink-installer": "^6.0",
"shlinkio/shlink-installer": "dev-develop#fa6a4ca as 6.1",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",

View File

@@ -42,6 +42,7 @@ return [
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,

View File

@@ -68,6 +68,7 @@ return [
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
IpAddress::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
Core\ErrorHandler\NotFoundRedirectHandler::class,
Core\ErrorHandler\NotFoundTemplateHandler::class,

View File

@@ -16,9 +16,12 @@ return [
'validate_url' => false, // Deprecated
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
'auto_resolve_titles' => false,
'append_extra_path' => false,
// TODO Move these two options to their own config namespace. Maybe "redirects".
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
'auto_resolve_titles' => false,
],
];

View File

@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View File

@@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration
try {
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
} catch (InvalidArgumentException) {
return null;
}
}

View File

@@ -111,9 +111,10 @@ return [
'validate_url' => (bool) env('VALIDATE_URLS', false),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
],
'tracking' => [

View File

@@ -21,6 +21,15 @@ if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
php bin/cli visit:download-db -n -q
fi
# Periodicaly run visit:locate every hour
# https://shlink.io/documentation/long-running-tasks/#locate-visits
# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
echo "Starting periodic visite locate..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
# When restarting the container, swoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View File

@@ -5,7 +5,7 @@
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
"parameters": [
{
"name": "shortCode",
@@ -35,10 +35,8 @@
"required": false,
"schema": {
"type": "string",
"enum": [
"png",
"svg"
]
"enum": ["png", "svg"],
"default": "png"
}
},
{
@@ -51,6 +49,17 @@
"minimum": 0,
"default": 0
}
},
{
"name": "errorCorrection",
"in": "query",
"description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
"required": false,
"schema": {
"type": "string",
"enum": ["L", "M", "Q", "H"],
"default": "L"
}
}
],
"responses": {

View File

@@ -10,11 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
class RoleResolver implements RoleResolverInterface
{
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
}
public function determineRoles(InputInterface $input): array

View File

@@ -19,12 +19,9 @@ class DisableKeyCommand extends Command
{
public const NAME = 'api-key:disable';
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(private ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void

View File

@@ -23,14 +23,11 @@ class GenerateKeyCommand extends BaseCommand
{
public const NAME = 'api-key:generate';
private ApiKeyServiceInterface $apiKeyService;
private RoleResolverInterface $roleResolver;
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
{
public function __construct(
private ApiKeyServiceInterface $apiKeyService,
private RoleResolverInterface $roleResolver
) {
parent::__construct();
$this->apiKeyService = $apiKeyService;
$this->roleResolver = $roleResolver;
}
protected function configure(): void

View File

@@ -27,12 +27,9 @@ class ListKeysCommand extends BaseCommand
public const NAME = 'api-key:list';
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(private ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void
@@ -61,7 +58,7 @@ class ListKeysCommand extends BaseCommand
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
empty($meta)

View File

@@ -22,7 +22,7 @@ abstract class BaseCommand extends Command
?string $shortcut = null,
?int $mode = null,
string $description = '',
$default = null
$default = null,
): self {
$this->addOption($name, $shortcut, $mode, $description, $default);

View File

@@ -13,16 +13,14 @@ use Symfony\Component\Process\PhpExecutableFinder;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private ProcessRunnerInterface $processRunner;
private string $phpBinary;
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
private ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder
) {
parent::__construct($locker);
$this->processRunner = $processRunner;
$this->phpBinary = $phpFinder->find(false) ?: 'php';
}

View File

@@ -21,19 +21,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
private Connection $regularConn;
private Connection $noDbNameConn;
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
Connection $conn,
Connection $noDbNameConn
private Connection $regularConn,
private Connection $noDbNameConn
) {
parent::__construct($locker, $processRunner, $phpFinder);
$this->regularConn = $conn;
$this->noDbNameConn = $noDbNameConn;
}
protected function configure(): void

View File

@@ -18,12 +18,9 @@ class ListDomainsCommand extends Command
{
public const NAME = 'domain:list';
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService)
{
parent::__construct();
$this->domainService = $domainService;
}
protected function configure(): void

View File

@@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command
{
public const NAME = 'short-url:delete';
private DeleteShortUrlServiceInterface $deleteShortUrlService;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
$this->deleteShortUrlService = $deleteShortUrlService;
}
protected function configure(): void

View File

@@ -30,19 +30,12 @@ class GenerateShortUrlCommand extends BaseCommand
{
public const NAME = 'short-url:generate';
private UrlShortenerInterface $urlShortener;
private ShortUrlStringifierInterface $stringifier;
private int $defaultShortCodeLength;
public function __construct(
UrlShortenerInterface $urlShortener,
ShortUrlStringifierInterface $stringifier,
int $defaultShortCodeLength
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength
) {
parent::__construct();
$this->urlShortener = $urlShortener;
$this->stringifier = $stringifier;
$this->defaultShortCodeLength = $defaultShortCodeLength;
}
protected function configure(): void

View File

@@ -27,11 +27,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
{
public const NAME = 'short-url:visits';
private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsStatsHelperInterface $visitsHelper)
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
{
$this->visitsHelper = $visitsHelper;
parent::__construct();
}

View File

@@ -33,14 +33,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
public const NAME = 'short-url:list';
private ShortUrlServiceInterface $shortUrlService;
private DataTransformerInterface $transformer;
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
{
public function __construct(
private ShortUrlServiceInterface $shortUrlService,
private DataTransformerInterface $transformer
) {
parent::__construct();
$this->shortUrlService = $shortUrlService;
$this->transformer = $transformer;
}
protected function doConfigure(): void
@@ -129,8 +126,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsOrdering::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
];
if ($all) {
@@ -158,7 +155,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
OutputInterface $output,
array $columnsMap,
ShortUrlsParams $params,
bool $all
bool $all,
): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params);
@@ -203,14 +200,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
(string) $shortUrl->authorApiKey();
(string) $shortUrl->authorApiKey();
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string {
$apiKey = $shortUrl->authorApiKey();
return $apiKey !== null ? $apiKey->name() : null;
};
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
$shortUrl->authorApiKey()?->name();
}
return $columnsMap;

View File

@@ -21,12 +21,9 @@ class ResolveUrlCommand extends Command
{
public const NAME = 'short-url:parse';
private ShortUrlResolverInterface $urlResolver;
public function __construct(ShortUrlResolverInterface $urlResolver)
public function __construct(private ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
$this->urlResolver = $urlResolver;
}
protected function configure(): void

View File

@@ -17,12 +17,9 @@ class CreateTagCommand extends Command
{
public const NAME = 'tag:create';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -16,12 +16,9 @@ class DeleteTagsCommand extends Command
{
public const NAME = 'tag:delete';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -18,12 +18,9 @@ class ListTagsCommand extends Command
{
public const NAME = 'tag:list';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -19,12 +19,9 @@ class RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -14,12 +14,9 @@ use function sprintf;
abstract class AbstractLockedCommand extends Command
{
private LockFactory $locker;
public function __construct(LockFactory $locker)
public function __construct(private LockFactory $locker)
{
parent::__construct();
$this->locker = $locker;
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int

View File

@@ -63,7 +63,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
));
if ($output->isVeryVerbose()) {
$this->getApplication()->renderThrowable($e, $output);
$this->getApplication()?->renderThrowable($e, $output);
}
return null;

View File

@@ -8,15 +8,11 @@ final class LockedCommandConfig
{
public const DEFAULT_TTL = 600.0; // 10 minutes
private string $lockName;
private bool $isBlocking;
private float $ttl;
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
{
$this->lockName = $lockName;
$this->isBlocking = $isBlocking;
$this->ttl = $ttl;
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL
) {
}
public static function blocking(string $lockName): self

View File

@@ -19,13 +19,11 @@ class DownloadGeoLiteDbCommand extends Command
{
public const NAME = 'visit:download-db';
private GeolocationDbUpdaterInterface $dbUpdater;
private ?ProgressBar $progressBar = null;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
{
parent::__construct();
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
@@ -71,7 +69,7 @@ class DownloadGeoLiteDbCommand extends Command
}
if ($io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $io);
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;

View File

@@ -30,19 +30,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
{
public const NAME = 'visit:locate';
private VisitLocatorInterface $visitLocator;
private IpLocationResolverInterface $ipLocationResolver;
private SymfonyStyle $io;
public function __construct(
VisitLocatorInterface $visitLocator,
IpLocationResolverInterface $ipLocationResolver,
private VisitLocatorInterface $visitLocator,
private IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker
) {
parent::__construct($locker);
$this->visitLocator = $visitLocator;
$this->ipLocationResolver = $ipLocationResolver;
}
protected function configure(): void
@@ -124,7 +119,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io);
$this->getApplication()?->renderThrowable($e, $this->io);
}
return ExitCodes::EXIT_FAILURE;
@@ -156,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
} catch (WrongIpException $e) {
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io);
$this->getApplication()?->renderThrowable($e, $this->io);
}
throw IpCannotBeLocatedException::forError($e);

View File

@@ -19,21 +19,12 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
private const LOCK_NAME = 'geolocation-db-update';
private DbUpdaterInterface $dbUpdater;
private Reader $geoLiteDbReader;
private LockFactory $locker;
private TrackingOptions $trackingOptions;
public function __construct(
DbUpdaterInterface $dbUpdater,
Reader $geoLiteDbReader,
LockFactory $locker,
TrackingOptions $trackingOptions
private DbUpdaterInterface $dbUpdater,
private Reader $geoLiteDbReader,
private LockFactory $locker,
private TrackingOptions $trackingOptions
) {
$this->dbUpdater = $dbUpdater;
$this->geoLiteDbReader = $geoLiteDbReader;
$this->locker = $locker;
$this->trackingOptions = $trackingOptions;
}
/**

View File

@@ -18,12 +18,10 @@ use function str_replace;
class ProcessRunner implements ProcessRunnerInterface
{
private ProcessHelper $helper;
private Closure $createProcess;
public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
{
$this->helper = $helper;
$this->createProcess = $createProcess !== null
? Closure::fromCallable($createProcess)
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);

View File

@@ -12,11 +12,8 @@ final class ShlinkTable
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
private ?Table $baseTable;
public function __construct(Table $baseTable)
public function __construct(private Table $baseTable)
{
$this->baseTable = $baseTable;
}
public static function fromOutput(OutputInterface $output): self

View File

@@ -33,7 +33,7 @@ class RoleResolverTest extends TestCase
public function properRolesAreResolvedBasedOnInput(
InputInterface $input,
array $expectedRoles,
int $expectedDomainCalls
int $expectedDomainCalls,
): void {
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
(new Domain('example.com'))->setId('1'),

View File

@@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,
int $expectedDeleteCalls,
string $expectedMessage
string $expectedMessage,
): void {
$shortCode = 'abc123';
$identifier = new ShortUrlIdentifier($shortCode);

View File

@@ -110,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase
array $input,
array $expectedContents,
array $notExpectedContents,
ApiKey $apiKey
ApiKey $apiKey,
): void {
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter([
@@ -185,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase
?string $searchTerm,
array $tags,
?string $startDate = null,
?string $endDate = null
?string $endDate = null,
): void {
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'page' => $page,

View File

@@ -36,7 +36,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
public function showsProperMessageWhenGeoLiteUpdateFails(
bool $olderDbExists,
string $expectedMessage,
int $expectedExitCode
int $expectedExitCode,
): void {
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists): void {

View File

@@ -73,7 +73,7 @@ class LocateVisitsCommandTest extends TestCase
int $expectedEmptyCalls,
int $expectedAllCalls,
bool $expectWarningPrint,
array $args
array $args,
): void {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());

View File

@@ -37,6 +37,7 @@ return [
Domain\DomainService::class => ConfigAbstractFactory::class,
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
Visit\RequestTracker::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
@@ -53,7 +54,9 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
@@ -69,7 +72,7 @@ return [
ConfigAbstractFactory::class => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [
NotFoundRedirectOptions::class,
Util\RedirectResponseHelper::class,
@@ -92,6 +95,7 @@ return [
EventDispatcherInterface::class,
Options\TrackingOptions::class,
],
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
Service\ShortUrlService::class => [
'em',
Service\ShortUrl\ShortUrlResolver::class,
@@ -116,17 +120,11 @@ return [
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\VisitsTracker::class,
Options\TrackingOptions::class,
Visit\RequestTracker::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
Util\RedirectResponseHelper::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\VisitsTracker::class,
Options\TrackingOptions::class,
'Logger_Shlink',
],
Action\PixelAction::class => [Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class],
Action\QrCodeAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlStringifier::class,
@@ -137,7 +135,15 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\RequestTracker::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
Util\RedirectResponseHelper::class,
Options\UrlShortenerOptions::class,
],
Mercure\MercureUpdatesGenerator::class => [
ShortUrl\Transformer\ShortUrlDataTransformer::class,

View File

@@ -51,20 +51,12 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
$startDate = parseDateFromQuery($query, $startDateName);
$endDate = parseDateFromQuery($query, $endDateName);
// TODO Use match expression when migrating to PHP8
if ($startDate === null && $endDate === null) {
return DateRange::emptyInstance();
}
if ($startDate !== null && $endDate !== null) {
return DateRange::withStartAndEndDate($startDate, $endDate);
}
if ($startDate !== null) {
return DateRange::withStartDate($startDate);
}
return DateRange::withEndDate($endDate);
return match (true) {
$startDate === null && $endDate === null => DateRange::emptyInstance(),
$startDate !== null && $endDate !== null => DateRange::withStartAndEndDate($startDate, $endDate),
$startDate !== null => DateRange::withStartDate($startDate),
default => DateRange::withEndDate($endDate),
};
}
/**

View File

@@ -5,91 +5,46 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\Psr7\Query;
use League\Uri\Uri;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use function array_key_exists;
use function array_merge;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker;
private TrackingOptions $trackingOptions;
private LoggerInterface $logger;
public function __construct(
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
TrackingOptions $trackingOptions,
?LoggerInterface $logger = null
private ShortUrlResolverInterface $urlResolver,
private RequestTrackerInterface $requestTracker,
) {
$this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker;
$this->trackingOptions = $trackingOptions;
$this->logger = $logger ?? new NullLogger();
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
$query = $request->getQueryParams();
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
try {
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
}
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
return $this->createSuccessResp($shortUrl, $request);
} catch (ShortUrlNotFoundException) {
return $this->createErrorResp($request, $handler);
}
}
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
{
$uri = Uri::createFromString($shortUrl->getLongUrl());
$hardcodedQuery = Query::parse($uri->getQuery() ?? '');
if ($disableTrackParam !== null) {
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(Query::build($mergedQuery)));
}
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
{
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
if ($forwardedMethod === self::METHOD_HEAD) {
return false;
}
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
}
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
abstract protected function createErrorResp(
abstract protected function createSuccessResp(
ShortUrl $shortUrl,
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
{
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action\Model;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use function strtolower;
use function trim;
final class QrCodeParams
{
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
private function __construct(
private int $size,
private int $margin,
private WriterInterface $writer,
private ErrorCorrectionLevelInterface $errorCorrectionLevel
) {
}
public static function fromRequest(ServerRequestInterface $request): self
{
$query = $request->getQueryParams();
return new self(
self::resolveSize($request, $query),
self::resolveMargin($query),
self::resolveWriter($query),
self::resolveErrorCorrection($query),
);
}
private static function resolveSize(Request $request, array $query): int
{
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
private static function resolveMargin(array $query): int
{
$margin = $query['margin'] ?? null;
if ($margin === null) {
return 0;
}
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
}
return $intMargin < 0 ? 0 : $intMargin;
}
private static function resolveWriter(array $query): WriterInterface
{
$format = strtolower(trim($query['format'] ?? 'png'));
return match ($format) {
'svg' => new SvgWriter(),
default => new PngWriter(),
};
}
private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
{
$errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
return match ($errorCorrectionLevel) {
'h' => new ErrorCorrectionLevelHigh(),
'q' => new ErrorCorrectionLevelQuartile(),
'm' => new ErrorCorrectionLevelMedium(),
default => new ErrorCorrectionLevelLow(), // 'l'
};
}
public function size(): int
{
return $this->size;
}
public function margin(): int
{
return $this->margin;
}
public function writer(): WriterInterface
{
return $this->writer;
}
public function errorCorrectionLevel(): ErrorCorrectionLevelInterface
{
return $this->errorCorrectionLevel;
}
}

View File

@@ -8,17 +8,18 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
class PixelAction extends AbstractTrackingAction
{
protected function createSuccessResp(string $longUrl): ResponseInterface
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): ResponseInterface
{
return new PixelResponse();
}
protected function createErrorResp(
ServerRequestInterface $request,
RequestHandlerInterface $handler
RequestHandlerInterface $handler,
): ResponseInterface {
return new PixelResponse();
}

View File

@@ -5,14 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\SvgWriter;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
@@ -20,22 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
class QrCodeAction implements MiddlewareInterface
{
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
private ShortUrlResolverInterface $urlResolver;
private ShortUrlStringifierInterface $stringifier;
private LoggerInterface $logger;
public function __construct(
ShortUrlResolverInterface $urlResolver,
ShortUrlStringifierInterface $stringifier,
?LoggerInterface $logger = null
private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger
) {
$this->urlResolver = $urlResolver;
$this->logger = $logger ?? new NullLogger();
$this->stringifier = $stringifier;
}
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -49,43 +37,14 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request);
}
$query = $request->getQueryParams();
$qrCode = Builder::create()
$params = QrCodeParams::fromRequest($request);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($this->resolveSize($request, $query))
->margin($this->resolveMargin($query));
->size($params->size())
->margin($params->margin())
->writer($params->writer())
->errorCorrectionLevel($params->errorCorrectionLevel());
$format = $query['format'] ?? 'png';
if ($format === 'svg') {
$qrCode->writer(new SvgWriter());
}
return new QrCodeResponse($qrCode->build());
}
private function resolveSize(Request $request, array $query): int
{
// Size attribute is deprecated. After v3.0.0, always use the query param instead
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
private function resolveMargin(array $query): int
{
if (! isset($query['margin'])) {
return 0;
}
$margin = $query['margin'];
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
}
return $intMargin < 0 ? 0 : $intMargin;
return new QrCodeResponse($qrCodeBuilder->build());
}
}

View File

@@ -7,35 +7,26 @@ namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
{
private RedirectResponseHelperInterface $redirectResponseHelper;
public function __construct(
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
Options\TrackingOptions $trackingOptions,
RedirectResponseHelperInterface $redirectResponseHelper,
?LoggerInterface $logger = null
RequestTrackerInterface $requestTracker,
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
private RedirectResponseHelperInterface $redirectResponseHelper,
) {
parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger);
$this->redirectResponseHelper = $redirectResponseHelper;
parent::__construct($urlResolver, $requestTracker);
}
protected function createSuccessResp(string $longUrl): Response
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response
{
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams());
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
}
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
{
return $handler->handle($request);
}
}

View File

@@ -17,11 +17,8 @@ use const PHP_EOL;
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
{
private CrawlingHelperInterface $crawlingHelper;
public function __construct(CrawlingHelperInterface $crawlingHelper)
public function __construct(private CrawlingHelperInterface $crawlingHelper)
{
$this->crawlingHelper = $crawlingHelper;
}
public function handle(ServerRequestInterface $request): ResponseInterface

View File

@@ -10,11 +10,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
class CrawlingHelper implements CrawlingHelperInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
public function __construct(private EntityManagerInterface $em)
{
$this->em = $em;
}
public function listCrawlableShortCodes(): iterable

View File

@@ -16,13 +16,8 @@ use function Functional\map;
class DomainService implements DomainServiceInterface
{
private EntityManagerInterface $em;
private string $defaultDomain;
public function __construct(EntityManagerInterface $em, string $defaultDomain)
public function __construct(private EntityManagerInterface $em, private string $defaultDomain)
{
$this->em = $em;
$this->defaultDomain = $defaultDomain;
}
/**
@@ -35,7 +30,7 @@ class DomainService implements DomainServiceInterface
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
$mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false));
if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) {
if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains;
}

View File

@@ -8,13 +8,8 @@ use JsonSerializable;
final class DomainItem implements JsonSerializable
{
private string $domain;
private bool $isDefault;
public function __construct(string $domain, bool $isDefault)
public function __construct(private string $domain, private bool $isDefault)
{
$this->domain = $domain;
$this->isDefault = $isDefault;
}
public function jsonSerialize(): array

View File

@@ -9,11 +9,8 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Domain extends AbstractEntity implements JsonSerializable
{
private string $authority;
public function __construct(string $authority)
public function __construct(private string $authority)
{
$this->authority = $authority;
}
public function getAuthority(): string

View File

@@ -60,7 +60,7 @@ class ShortUrl extends AbstractEntity
public static function fromMeta(
ShortUrlMeta $meta,
?ShortUrlRelationResolverInterface $relationResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null,
): self {
$instance = new self();
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
@@ -87,7 +87,7 @@ class ShortUrl extends AbstractEntity
public static function fromImport(
ImportedShlinkUrl $url,
bool $importShortCode,
?ShortUrlRelationResolverInterface $relationResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null,
): self {
$meta = [
ShortUrlInputFilter::VALIDATE_URL => false,
@@ -209,7 +209,7 @@ class ShortUrl extends AbstractEntity
public function update(
ShortUrlEdit $shortUrlEdit,
?ShortUrlRelationResolverInterface $relationResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null,
): void {
if ($shortUrlEdit->validSinceWasProvided()) {
$this->validSince = $shortUrlEdit->validSince();

View File

@@ -10,12 +10,10 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Tag extends AbstractEntity implements JsonSerializable
{
private string $name;
private Collections\Collection $shortUrls;
public function __construct(string $name)
public function __construct(private string $name)
{
$this->name = $name;
$this->shortUrls = new Collections\ArrayCollection();
}

View File

@@ -104,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable
try {
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
} catch (InvalidArgumentException) {
return null;
}
}

View File

@@ -13,31 +13,24 @@ use function rtrim;
class NotFoundType
{
private string $type;
private function __construct(string $type)
private function __construct(private string $type)
{
$this->type = $type;
}
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
{
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
if ($isBaseUrl) {
return new self(Visit::TYPE_BASE_URL);
}
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
if ($routeResult->isFailure()) {
return new self(Visit::TYPE_REGULAR_404);
}
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
return new self(Visit::TYPE_INVALID_SHORT_URL);
}
$type = match (true) {
$isBaseUrl => Visit::TYPE_BASE_URL,
$routeResult->isFailure() => Visit::TYPE_REGULAR_404,
$routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL,
default => self::class,
};
return new self(self::class);
return new self($type);
}
public function isBaseUrl(): bool

View File

@@ -14,15 +14,10 @@ use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class NotFoundRedirectHandler implements MiddlewareInterface
{
private Options\NotFoundRedirectOptions $redirectOptions;
private RedirectResponseHelperInterface $redirectResponseHelper;
public function __construct(
Options\NotFoundRedirectOptions $redirectOptions,
RedirectResponseHelperInterface $redirectResponseHelper
private Options\NotFoundRedirectOptions $redirectOptions,
private RedirectResponseHelperInterface $redirectResponseHelper
) {
$this->redirectOptions = $redirectOptions;
$this->redirectResponseHelper = $redirectResponseHelper;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

View File

@@ -20,6 +20,7 @@ class NotFoundTemplateHandler implements RequestHandlerInterface
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
public const NOT_FOUND_TEMPLATE = '404.html';
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
private Closure $readFile;
public function __construct(?callable $readFile = null)

View File

@@ -8,33 +8,17 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
class NotFoundTrackerMiddleware implements MiddlewareInterface
{
private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker)
public function __construct(private RequestTrackerInterface $requestTracker)
{
$this->visitsTracker = $visitsTracker;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
$visitor = Visitor::fromRequest($request);
if ($notFoundType->isBaseUrl()) {
$this->visitsTracker->trackBaseUrlVisit($visitor);
} elseif ($notFoundType->isRegularNotFound()) {
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
} elseif ($notFoundType->isInvalidShortUrl()) {
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
}
$this->requestTracker->trackNotFoundIfApplicable($request);
return $handler->handle($request);
}
}

View File

@@ -12,11 +12,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
class NotFoundTypeResolverMiddleware implements MiddlewareInterface
{
private string $shlinkBasePath;
public function __construct(string $shlinkBasePath)
public function __construct(private string $shlinkBasePath)
{
$this->shlinkBasePath = $shlinkBasePath;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

View File

@@ -8,13 +8,11 @@ use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
class CloseDbConnectionEventListener
{
private ReopeningEntityManagerInterface $em;
/** @var callable */
private $wrapped;
public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
{
$this->em = $em;
$this->wrapped = $wrapped;
}

View File

@@ -12,7 +12,7 @@ class CloseDbConnectionEventListenerDelegator
public function __invoke(
ContainerInterface $container,
string $name,
callable $callback
callable $callback,
): CloseDbConnectionEventListener {
/** @var callable $wrapped */
$wrapped = $callback();

View File

@@ -8,11 +8,8 @@ use JsonSerializable;
abstract class AbstractVisitEvent implements JsonSerializable
{
protected string $visitId;
public function __construct(string $visitId)
public function __construct(protected string $visitId)
{
$this->visitId = $visitId;
}
public function visitId(): string

View File

@@ -6,12 +6,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
final class UrlVisited extends AbstractVisitEvent
{
private ?string $originalIpAddress;
public function __construct(string $visitId, ?string $originalIpAddress = null)
public function __construct(string $visitId, private ?string $originalIpAddress = null)
{
parent::__construct($visitId);
$this->originalIpAddress = $originalIpAddress;
}
public function originalIpAddress(): ?string

View File

@@ -19,24 +19,13 @@ use Throwable;
class LocateVisit
{
private IpLocationResolverInterface $ipLocationResolver;
private EntityManagerInterface $em;
private LoggerInterface $logger;
private DbUpdaterInterface $dbUpdater;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
IpLocationResolverInterface $ipLocationResolver,
EntityManagerInterface $em,
LoggerInterface $logger,
DbUpdaterInterface $dbUpdater,
EventDispatcherInterface $eventDispatcher
private IpLocationResolverInterface $ipLocationResolver,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private DbUpdaterInterface $dbUpdater,
private EventDispatcherInterface $eventDispatcher
) {
$this->ipLocationResolver = $ipLocationResolver;
$this->em = $em;
$this->logger = $logger;
$this->dbUpdater = $dbUpdater;
$this->eventDispatcher = $eventDispatcher;
}
public function __invoke(UrlVisited $shortUrlVisited): void

View File

@@ -17,21 +17,12 @@ use function Functional\each;
class NotifyVisitToMercure
{
private HubInterface $hub;
private MercureUpdatesGeneratorInterface $updatesGenerator;
private EntityManagerInterface $em;
private LoggerInterface $logger;
public function __construct(
HubInterface $hub,
MercureUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger
private HubInterface $hub,
private MercureUpdatesGeneratorInterface $updatesGenerator,
private EntityManagerInterface $em,
private LoggerInterface $logger
) {
$this->hub = $hub;
$this->em = $em;
$this->logger = $logger;
$this->updatesGenerator = $updatesGenerator;
}
public function __invoke(VisitLocated $shortUrlLocated): void

View File

@@ -24,28 +24,15 @@ use function Functional\partial_left;
class NotifyVisitToWebHooks
{
private ClientInterface $httpClient;
private EntityManagerInterface $em;
private LoggerInterface $logger;
/** @var string[] */
private array $webhooks;
private DataTransformerInterface $transformer;
private AppOptions $appOptions;
public function __construct(
ClientInterface $httpClient,
EntityManagerInterface $em,
LoggerInterface $logger,
array $webhooks,
DataTransformerInterface $transformer,
AppOptions $appOptions
private ClientInterface $httpClient,
private EntityManagerInterface $em,
private LoggerInterface $logger,
/** @var string[] */
private array $webhooks,
private DataTransformerInterface $transformer,
private AppOptions $appOptions
) {
$this->httpClient = $httpClient;
$this->em = $em;
$this->logger = $logger;
$this->webhooks = $webhooks;
$this->transformer = $transformer;
$this->appOptions = $appOptions;
}
public function __invoke(VisitLocated $shortUrlLocated): void

View File

@@ -12,13 +12,8 @@ use function sprintf;
class UpdateGeoLiteDb
{
private GeolocationDbUpdaterInterface $dbUpdater;
private LoggerInterface $logger;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater, LoggerInterface $logger)
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater, private LoggerInterface $logger)
{
$this->dbUpdater = $dbUpdater;
$this->logger = $logger;
}
public function __invoke(): void

View File

@@ -20,22 +20,14 @@ use function sprintf;
class ImportedLinksProcessor implements ImportedLinksProcessorInterface
{
private EntityManagerInterface $em;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
private DoctrineBatchHelperInterface $batchHelper;
private ShortUrlRepositoryInterface $shortUrlRepo;
public function __construct(
EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper,
DoctrineBatchHelperInterface $batchHelper
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
private ShortCodeHelperInterface $shortCodeHelper,
private DoctrineBatchHelperInterface $batchHelper
) {
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
$this->batchHelper = $batchHelper;
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line
}
@@ -64,7 +56,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
try {
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
} catch (NonUniqueSlugException $e) {
} catch (NonUniqueSlugException) {
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
continue;
}
@@ -77,7 +69,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private function resolveShortUrl(
ImportedShlinkUrl $importedUrl,
bool $importShortCodes,
callable $skipOnShortCodeConflict
callable $skipOnShortCodeConflict,
): ShortUrlImporting {
$alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl);
if ($alreadyImportedShortUrl !== null) {
@@ -96,7 +88,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private function handleShortCodeUniqueness(
ShortUrl $shortUrl,
bool $importShortCodes,
callable $skipOnShortCodeConflict
callable $skipOnShortCodeConflict,
): bool {
if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) {
return true;

View File

@@ -14,13 +14,8 @@ use function sprintf;
final class ShortUrlImporting
{
private ShortUrl $shortUrl;
private bool $isNew;
private function __construct(ShortUrl $shortUrl, bool $isNew)
private function __construct(private ShortUrl $shortUrl, private bool $isNew)
{
$this->shortUrl = $shortUrl;
$this->isNew = $isNew;
}
public static function fromExistingShortUrl(ShortUrl $shortUrl): self
@@ -43,10 +38,7 @@ final class ShortUrlImporting
$importedVisits = 0;
foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date
if (
$mostRecentImportedDate !== null
&& $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date()))
) {
if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date()))) {
continue;
}

View File

@@ -18,15 +18,10 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit';
private DataTransformerInterface $shortUrlTransformer;
private DataTransformerInterface $orphanVisitTransformer;
public function __construct(
DataTransformerInterface $shortUrlTransformer,
DataTransformerInterface $orphanVisitTransformer
private DataTransformerInterface $shortUrlTransformer,
private DataTransformerInterface $orphanVisitTransformer
) {
$this->shortUrlTransformer = $shortUrlTransformer;
$this->orphanVisitTransformer = $orphanVisitTransformer;
}
public function newVisitUpdate(Visit $visit): Update

View File

@@ -10,13 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
final class ShortUrlIdentifier
{
private string $shortCode;
private ?string $domain;
public function __construct(string $shortCode, ?string $domain = null)
public function __construct(private string $shortCode, private ?string $domain = null)
{
$this->shortCode = $shortCode;
$this->domain = $domain;
}
public static function fromApiRequest(ServerRequestInterface $request): self
@@ -46,7 +41,7 @@ final class ShortUrlIdentifier
public static function fromShortUrl(ShortUrl $shortUrl): self
{
$domain = $shortUrl->getDomain();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
$domainAuthority = $domain?->getAuthority();
return new self($shortUrl->getShortCode(), $domainAuthority);
}

View File

@@ -14,20 +14,16 @@ final class VisitsParams
private const ALL_ITEMS = -1;
private ?DateRange $dateRange;
private int $page;
private int $itemsPerPage;
private bool $excludeBots;
public function __construct(
?DateRange $dateRange = null,
int $page = self::FIRST_PAGE,
private int $page = self::FIRST_PAGE,
?int $itemsPerPage = null,
bool $excludeBots = false
private bool $excludeBots = false
) {
$this->dateRange = $dateRange ?? new DateRange();
$this->page = $page;
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
$this->excludeBots = $excludeBots;
}
private function determineItemsPerPage(?int $itemsPerPage): int

View File

@@ -19,6 +19,7 @@ class UrlShortenerOptions extends AbstractOptions
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
private bool $autoResolveTitles = false;
private bool $appendExtraPath = false;
public function isUrlValidationEnabled(): bool
{
@@ -67,6 +68,16 @@ class UrlShortenerOptions extends AbstractOptions
$this->autoResolveTitles = $autoResolveTitles;
}
public function appendExtraPath(): bool
{
return $this->appendExtraPath;
}
protected function setAppendExtraPath(bool $appendExtraPath): void
{
$this->appendExtraPath = $appendExtraPath;
}
/** @deprecated */
protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
{

View File

@@ -11,13 +11,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
private VisitRepositoryInterface $repo;
private VisitsParams $params;
public function __construct(VisitRepositoryInterface $repo, VisitsParams $params)
public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params)
{
$this->repo = $repo;
$this->params = $params;
}
protected function doCount(): int

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
@@ -12,15 +11,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
{
private ShortUrlRepositoryInterface $repository;
private ShortUrlsParams $params;
private ?ApiKey $apiKey;
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey)
{
$this->repository = $repository;
$this->params = $params;
$this->apiKey = $apiKey;
public function __construct(
private ShortUrlRepositoryInterface $repository,
private ShortUrlsParams $params,
private ?ApiKey $apiKey
) {
}
public function getSlice($offset, $length): array // phpcs:ignore
@@ -32,7 +27,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->tags(),
$this->params->orderBy(),
$this->params->dateRange(),
$this->resolveSpec(),
$this->apiKey?->spec(),
);
}
@@ -42,12 +37,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->searchTerm(),
$this->params->tags(),
$this->params->dateRange(),
$this->resolveSpec(),
$this->apiKey?->spec(),
);
}
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec() : null;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
@@ -13,21 +12,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
private VisitRepositoryInterface $visitRepository;
private string $tag;
private VisitsParams $params;
private ?ApiKey $apiKey;
public function __construct(
VisitRepositoryInterface $visitRepository,
string $tag,
VisitsParams $params,
?ApiKey $apiKey
private VisitRepositoryInterface $visitRepository,
private string $tag,
private VisitsParams $params,
private ?ApiKey $apiKey
) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->tag = $tag;
$this->apiKey = $apiKey;
}
public function getSlice($offset, $length): array // phpcs:ignore
@@ -37,7 +27,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsListFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
$this->resolveSpec(),
$this->apiKey?->spec(true),
$length,
$offset,
),
@@ -51,13 +41,8 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsCountFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
$this->resolveSpec(),
$this->apiKey?->spec(true),
),
);
}
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec(true) : null;
}
}

View File

@@ -13,21 +13,12 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
private VisitRepositoryInterface $visitRepository;
private ShortUrlIdentifier $identifier;
private VisitsParams $params;
private ?Specification $spec;
public function __construct(
VisitRepositoryInterface $visitRepository,
ShortUrlIdentifier $identifier,
VisitsParams $params,
?Specification $spec
private VisitRepositoryInterface $visitRepository,
private ShortUrlIdentifier $identifier,
private VisitsParams $params,
private ?Specification $spec
) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->identifier = $identifier;
$this->spec = $spec;
}
public function getSlice($offset, $length): array // phpcs:ignore

View File

@@ -35,7 +35,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
array $tags = [],
?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null,
?Specification $spec = null
?Specification $spec = null,
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
$qb->select('DISTINCT s')
@@ -43,7 +43,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
->setFirstResult($offset);
// In case the ordering has been specified, the query could be more complex. Process it
if ($orderBy !== null && $orderBy->hasOrderField()) {
if ($orderBy?->hasOrderField()) {
return $this->processOrderByForList($qb, $orderBy);
}
@@ -85,7 +85,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null,
?Specification $spec = null
?Specification $spec = null,
): int {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
$qb->select('COUNT(DISTINCT s)');
@@ -97,17 +97,17 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
?string $searchTerm,
array $tags,
?DateRange $dateRange,
?Specification $spec
?Specification $spec,
): QueryBuilder {
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
->where('1=1');
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
if ($dateRange?->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
if ($dateRange?->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
$qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}

View File

@@ -23,14 +23,14 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
array $tags = [],
?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null,
?Specification $spec = null
?Specification $spec = null,
): array;
public function countList(
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null,
?Specification $spec = null
?Specification $spec = null,
): int;
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;

View File

@@ -71,14 +71,14 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$iterator = $qb->getQuery()->toIterable();
$resultsFound = false;
/** @var Visit $visit */
foreach ($iterator as $key => $visit) {
$resultsFound = true;
yield $key => $visit;
}
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
$lastId = isset($visit) ? $visit->getId() : $lastId;
/** @var Visit|null $visit */
$lastId = $visit?->getId() ?? $lastId;
} while ($resultsFound);
}
@@ -101,12 +101,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function createVisitsByShortCodeQueryBuilder(
ShortUrlIdentifier $identifier,
VisitsCountFiltering $filtering
VisitsCountFiltering $filtering,
): QueryBuilder {
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec());
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
$shortUrlId = $shortUrl?->getId() ?? '-1';
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
// Since they are not strictly provided by the caller, it's reasonably safe
@@ -187,10 +187,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
{
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
if ($dateRange?->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
}
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
if ($dateRange?->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
}
}

View File

@@ -13,18 +13,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
private EntityManagerInterface $em;
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
private ShortUrlResolverInterface $urlResolver;
public function __construct(
EntityManagerInterface $em,
DeleteShortUrlsOptions $deleteShortUrlsOptions,
ShortUrlResolverInterface $urlResolver
private EntityManagerInterface $em,
private DeleteShortUrlsOptions $deleteShortUrlsOptions,
private ShortUrlResolverInterface $urlResolver
) {
$this->em = $em;
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
$this->urlResolver = $urlResolver;
}
/**
@@ -34,7 +27,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
public function deleteByShortCode(
ShortUrlIdentifier $identifier,
bool $ignoreThreshold = false,
?ApiKey $apiKey = null
?ApiKey $apiKey = null,
): void {
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {

View File

@@ -17,6 +17,6 @@ interface DeleteShortUrlServiceInterface
public function deleteByShortCode(
ShortUrlIdentifier $identifier,
bool $ignoreThreshold = false,
?ApiKey $apiKey = null
?ApiKey $apiKey = null,
): void;
}

View File

@@ -11,11 +11,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
public function __construct(private EntityManagerInterface $em)
{
$this->em = $em;
}
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool

View File

@@ -13,11 +13,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlResolver implements ShortUrlResolverInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
public function __construct(private EntityManagerInterface $em)
{
$this->em = $em;
}
/**
@@ -27,7 +24,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null);
$shortUrl = $shortUrlRepo->findOne($identifier, $apiKey?->spec());
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
@@ -43,7 +40,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain());
if ($shortUrl === null || ! $shortUrl->isEnabled()) {
if (! $shortUrl?->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}

View File

@@ -21,21 +21,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlService implements ShortUrlServiceInterface
{
private ORM\EntityManagerInterface $em;
private ShortUrlResolverInterface $urlResolver;
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private ShortUrlRelationResolverInterface $relationResolver;
public function __construct(
ORM\EntityManagerInterface $em,
ShortUrlResolverInterface $urlResolver,
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
ShortUrlRelationResolverInterface $relationResolver
private ORM\EntityManagerInterface $em,
private ShortUrlResolverInterface $urlResolver,
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private ShortUrlRelationResolverInterface $relationResolver
) {
$this->em = $em;
$this->urlResolver = $urlResolver;
$this->titleResolutionHelper = $titleResolutionHelper;
$this->relationResolver = $relationResolver;
}
/**
@@ -59,7 +50,7 @@ class ShortUrlService implements ShortUrlServiceInterface
public function updateShortUrl(
ShortUrlIdentifier $identifier,
ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null
?ApiKey $apiKey = null,
): ShortUrl {
if ($shortUrlEdit->longUrlWasProvided()) {
/** @var ShortUrlEdit $shortUrlEdit */

View File

@@ -27,6 +27,6 @@ interface ShortUrlServiceInterface
public function updateShortUrl(
ShortUrlIdentifier $identifier,
ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null
?ApiKey $apiKey = null,
): ShortUrl;
}

View File

@@ -16,21 +16,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
class UrlShortener implements UrlShortenerInterface
{
private EntityManagerInterface $em;
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
public function __construct(
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
private ShortCodeHelperInterface $shortCodeHelper
) {
$this->titleResolutionHelper = $titleResolutionHelper;
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
}
/**
@@ -78,7 +69,7 @@ class UrlShortener implements UrlShortenerInterface
if (! $couldBeMadeUnique) {
$domain = $shortUrlToBeCreated->getDomain();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
$domainAuthority = $domain?->getAuthority();
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use GuzzleHttp\Psr7\Query;
use League\Uri\Uri;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function array_merge;
use function sprintf;
class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
{
public function __construct(private TrackingOptions $trackingOptions)
{
}
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string
{
$uri = Uri::createFromString($shortUrl->getLongUrl());
return $uri
->withQuery($this->resolveQuery($uri, $currentQuery))
->withPath($this->resolvePath($uri, $extraPath))
->__toString();
}
private function resolveQuery(Uri $uri, array $currentQuery): ?string
{
$hardcodedQuery = Query::parse($uri->getQuery() ?? '');
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
if ($disableTrackParam !== null) {
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
return empty($mergedQuery) ? null : Query::build($mergedQuery);
}
private function resolvePath(Uri $uri, ?string $extraPath): string
{
$hardcodedPath = $uri->getPath();
return $extraPath === null ? $hardcodedPath : sprintf('%s%s', $hardcodedPath, $extraPath);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
interface ShortUrlRedirectionBuilderInterface
{
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string;
}

View File

@@ -11,13 +11,8 @@ use function sprintf;
class ShortUrlStringifier implements ShortUrlStringifierInterface
{
private array $domainConfig;
private string $basePath;
public function __construct(array $domainConfig, string $basePath = '')
public function __construct(private array $domainConfig, private string $basePath = '')
{
$this->domainConfig = $domainConfig;
$this->basePath = $basePath;
}
public function stringify(ShortUrl $shortUrl): string

View File

@@ -8,11 +8,8 @@ use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{
private UrlValidatorInterface $urlValidator;
public function __construct(UrlValidatorInterface $urlValidator)
public function __construct(private UrlValidatorInterface $urlValidator)
{
$this->urlValidator = $urlValidator;
}
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
use function array_pad;
use function explode;
use function sprintf;
use function trim;
class ExtraPathRedirectMiddleware implements MiddlewareInterface
{
public function __construct(
private ShortUrlResolverInterface $resolver,
private RequestTrackerInterface $requestTracker,
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
private RedirectResponseHelperInterface $redirectResponseHelper,
private UrlShortenerOptions $urlShortenerOptions,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NotFoundType|null $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
// We'll apply this logic only if actively opted in and current URL is potentially /{shortCode}/[...]
if (! $notFoundType?->isRegularNotFound() || ! $this->urlShortenerOptions->appendExtraPath()) {
return $handler->handle($request);
}
$uri = $request->getUri();
$query = $request->getQueryParams();
[$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority());
try {
$shortUrl = $this->resolver->resolveEnabledShortUrl($identifier);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath);
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
} catch (ShortUrlNotFoundException) {
return $handler->handle($request);
}
}
/**
* @return array{0: string, 1: string|null}
*/
private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri): array
{
$pathParts = explode('/', trim($uri->getPath(), '/'), 2);
[$potentialShortCode, $extraPath] = array_pad($pathParts, 2, null);
return [$potentialShortCode, $extraPath === null ? null : sprintf('/%s', $extraPath)];
}
}

View File

@@ -16,16 +16,13 @@ use function Functional\unique;
class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{
private EntityManagerInterface $em;
/** @var array<string, Domain> */
private array $memoizedNewDomains = [];
/** @var array<string, Tag> */
private array $memoizedNewTags = [];
public function __construct(EntityManagerInterface $em)
public function __construct(private EntityManagerInterface $em)
{
$this->em = $em;
$this->em->getEventManager()->addEventListener(Events::postFlush, $this);
}

View File

@@ -11,13 +11,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class BelongsToApiKey extends BaseSpecification
{
private ApiKey $apiKey;
private ?string $dqlAlias;
public function __construct(ApiKey $apiKey, ?string $dqlAlias = null)
public function __construct(private ApiKey $apiKey, private ?string $dqlAlias = null)
{
$this->apiKey = $apiKey;
$this->dqlAlias = $dqlAlias;
parent::__construct();
}

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