Compare commits

..

8 Commits

Author SHA1 Message Date
Alejandro Celaya
32fda231ad Merge pull request #1138 from acelaya-forks/feature/fix-import-with-no-visits
Feature/fix import with no visits
2021-08-02 20:34:06 +02:00
Alejandro Celaya
e4d4686717 Ensure visits lists where the page is lower than 1, fall back to page 1 to avoid errors 2021-08-02 20:22:07 +02:00
Alejandro Celaya
ca6c6a1b6e Updated importer to v2.3.1 2021-08-02 18:29:16 +02:00
Alejandro Celaya
51c7d0ed3e Removed deprecated env var for publish release 2021-07-30 18:25:00 +02:00
Alejandro Celaya
b3af493758 Merge pull request #1130 from acelaya-forks/feature/docker-memory-limit
Fixed memory too low limit on docker image
2021-07-30 18:16:40 +02:00
Alejandro Celaya
7b9ebbbb5f Fixed use of ImplicitOptionsMiddleware with its new signature 2021-07-30 18:05:03 +02:00
Alejandro Celaya
ea735fc0a0 Ensured guzzle/psr7 1.7 is used as the project still has deprecated calls 2021-07-30 17:48:43 +02:00
Alejandro Celaya
06227e97d0 Fixed memory too low limit on docker image 2021-07-30 17:39:45 +02:00
198 changed files with 1146 additions and 1157 deletions

View File

@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '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 == '8.0' }}
if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-unit
path: |
@@ -74,7 +74,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '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 == '8.0' }}
if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-db
path: |
@@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -120,7 +120,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -140,7 +140,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -160,7 +160,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -184,7 +184,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '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 == '8.0' }}
if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-api
path: |
@@ -216,7 +216,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['7.4', '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: ['8.0']
php-version: ['7.4', '8.0']
swoole: ['yes', 'no']
steps:
- name: Checkout code
@@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: [ '8.0' ]
php-version: [ '7.4', '8.0' ]
swoole: [ 'yes', 'no' ]
steps:
- uses: geekyeggo/delete-artifact@v1

View File

@@ -4,18 +4,9 @@ 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]
## [2.7.3] - 2021-08-02
### 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.
* *Nothing*
### Changed
* *Nothing*
@@ -24,11 +15,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
### Removed
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
* *Nothing*
### Fixed
* [#1135](https://github.com/shlinkio/shlink/issues/1135) Fixed error when importing short URLs with no visits from another Shlink instance.
* [#1136](https://github.com/shlinkio/shlink/issues/1136) Fixed error when fetching tag/short-url/orphan visits for a page lower than 1.
## [2.7.2] - 2021-07-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1128](https://github.com/shlinkio/shlink/issues/1128) Increased memory limit reserved for the docker image, preventing it from crashing on GeoLite db download.
## [2.7.1] - 2021-05-30
### Added

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` to 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` ti 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,13 +78,4 @@ 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 8.0
* PHP 7.4 or 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": "^8.0",
"php": "^7.4 || ^8.0",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.0",
@@ -24,6 +24,7 @@
"endroid/qr-code": "^4.0",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
"guzzlehttp/psr7": "^1.7",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2",
"laminas/laminas-config": "^3.3",
@@ -50,8 +51,8 @@
"shlinkio/shlink-common": "^3.7",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3",
"shlinkio/shlink-installer": "dev-develop#fa6a4ca as 6.1",
"shlinkio/shlink-importer": "^2.3.1",
"shlinkio/shlink-installer": "^6.0",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",

View File

@@ -42,7 +42,6 @@ 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,7 +68,6 @@ 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,12 +16,9 @@ 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/php8.0-fpm.sock;
fastcgi_pass unix:/var/run/php/php7.4-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) {
} catch (InvalidArgumentException $e) {
return null;
}
}

View File

@@ -1,3 +1,4 @@
log_errors_max_len=0
zend.assertions=1
assert.exception=1
memory_limit=256M

View File

@@ -111,10 +111,9 @@ 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),
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
],
'tracking' => [

View File

@@ -21,15 +21,6 @@ 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.<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.",
"description": "Generates a QR code image pointing to a short URL",
"parameters": [
{
"name": "shortCode",
@@ -35,8 +35,10 @@
"required": false,
"schema": {
"type": "string",
"enum": ["png", "svg"],
"default": "png"
"enum": [
"png",
"svg"
]
}
},
{
@@ -49,17 +51,6 @@
"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,8 +10,11 @@ use Symfony\Component\Console\Input\InputInterface;
class RoleResolver implements RoleResolverInterface
{
public function __construct(private DomainServiceInterface $domainService)
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
}
public function determineRoles(InputInterface $input): array

View File

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

View File

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

View File

@@ -27,9 +27,12 @@ class ListKeysCommand extends BaseCommand
public const NAME = 'api-key:list';
public function __construct(private ApiKeyServiceInterface $apiKeyService)
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void
@@ -58,7 +61,7 @@ class ListKeysCommand extends BaseCommand
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $expiration !== null ? $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,14 +13,16 @@ use Symfony\Component\Process\PhpExecutableFinder;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private ProcessRunnerInterface $processRunner;
private string $phpBinary;
public function __construct(
LockFactory $locker,
private ProcessRunnerInterface $processRunner,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder
) {
parent::__construct($locker);
$this->processRunner = $processRunner;
$this->phpBinary = $phpFinder->find(false) ?: 'php';
}

View File

@@ -21,14 +21,19 @@ 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,
private Connection $regularConn,
private Connection $noDbNameConn
Connection $conn,
Connection $noDbNameConn
) {
parent::__construct($locker, $processRunner, $phpFinder);
$this->regularConn = $conn;
$this->noDbNameConn = $noDbNameConn;
}
protected function configure(): void

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,12 @@ use function sprintf;
abstract class AbstractLockedCommand extends Command
{
public function __construct(private LockFactory $locker)
private LockFactory $locker;
public function __construct(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,11 +8,15 @@ final class LockedCommandConfig
{
public const DEFAULT_TTL = 600.0; // 10 minutes
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL
) {
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;
}
public static function blocking(string $lockName): self

View File

@@ -19,11 +19,13 @@ class DownloadGeoLiteDbCommand extends Command
{
public const NAME = 'visit:download-db';
private GeolocationDbUpdaterInterface $dbUpdater;
private ?ProgressBar $progressBar = null;
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
{
parent::__construct();
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
@@ -69,7 +71,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,14 +30,19 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
{
public const NAME = 'visit:locate';
private VisitLocatorInterface $visitLocator;
private IpLocationResolverInterface $ipLocationResolver;
private SymfonyStyle $io;
public function __construct(
private VisitLocatorInterface $visitLocator,
private IpLocationResolverInterface $ipLocationResolver,
VisitLocatorInterface $visitLocator,
IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker
) {
parent::__construct($locker);
$this->visitLocator = $visitLocator;
$this->ipLocationResolver = $ipLocationResolver;
}
protected function configure(): void
@@ -119,7 +124,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;
@@ -151,7 +156,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,12 +19,21 @@ 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(
private DbUpdaterInterface $dbUpdater,
private Reader $geoLiteDbReader,
private LockFactory $locker,
private TrackingOptions $trackingOptions
DbUpdaterInterface $dbUpdater,
Reader $geoLiteDbReader,
LockFactory $locker,
TrackingOptions $trackingOptions
) {
$this->dbUpdater = $dbUpdater;
$this->geoLiteDbReader = $geoLiteDbReader;
$this->locker = $locker;
$this->trackingOptions = $trackingOptions;
}
/**

View File

@@ -18,10 +18,12 @@ use function str_replace;
class ProcessRunner implements ProcessRunnerInterface
{
private ProcessHelper $helper;
private Closure $createProcess;
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
public function __construct(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,8 +12,11 @@ final class ShlinkTable
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
public function __construct(private Table $baseTable)
private ?Table $baseTable;
public function __construct(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,7 +37,6 @@ 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,
@@ -54,9 +53,7 @@ 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,
@@ -72,7 +69,7 @@ return [
ConfigAbstractFactory::class => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [
NotFoundRedirectOptions::class,
Util\RedirectResponseHelper::class,
@@ -95,7 +92,6 @@ return [
EventDispatcherInterface::class,
Options\TrackingOptions::class,
],
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
Service\ShortUrlService::class => [
'em',
Service\ShortUrl\ShortUrlResolver::class,
@@ -120,11 +116,17 @@ return [
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\RequestTracker::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
Visit\VisitsTracker::class,
Options\TrackingOptions::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,
@@ -135,15 +137,7 @@ 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,12 +51,20 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
$startDate = parseDateFromQuery($query, $startDateName);
$endDate = parseDateFromQuery($query, $endDateName);
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),
};
// 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);
}
/**

View File

@@ -5,46 +5,91 @@ 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\RequestTrackerInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use function array_key_exists;
use function array_merge;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker;
private TrackingOptions $trackingOptions;
private LoggerInterface $logger;
public function __construct(
private ShortUrlResolverInterface $urlResolver,
private RequestTrackerInterface $requestTracker,
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
TrackingOptions $trackingOptions,
?LoggerInterface $logger = null
) {
$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);
return $this->createSuccessResp($shortUrl, $request);
} catch (ShortUrlNotFoundException) {
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->createErrorResp($request, $handler);
}
}
abstract protected function createSuccessResp(
ShortUrl $shortUrl,
ServerRequestInterface $request,
): ResponseInterface;
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
{
return $handler->handle($request);
$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(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
}

View File

@@ -1,112 +0,0 @@
<?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,18 +8,17 @@ 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(ShortUrl $shortUrl, ServerRequestInterface $request): ResponseInterface
protected function createSuccessResp(string $longUrl): ResponseInterface
{
return new PixelResponse();
}
protected function createErrorResp(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
RequestHandlerInterface $handler
): ResponseInterface {
return new PixelResponse();
}

View File

@@ -5,13 +5,14 @@ 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;
@@ -19,11 +20,22 @@ 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(
private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger
ShortUrlResolverInterface $urlResolver,
ShortUrlStringifierInterface $stringifier,
?LoggerInterface $logger = null
) {
$this->urlResolver = $urlResolver;
$this->logger = $logger ?? new NullLogger();
$this->stringifier = $stringifier;
}
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -37,14 +49,43 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request);
}
$params = QrCodeParams::fromRequest($request);
$qrCodeBuilder = Builder::create()
$query = $request->getQueryParams();
$qrCode = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($params->size())
->margin($params->margin())
->writer($params->writer())
->errorCorrectionLevel($params->errorCorrectionLevel());
->size($this->resolveSize($request, $query))
->margin($this->resolveMargin($query));
return new QrCodeResponse($qrCodeBuilder->build());
$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;
}
}

View File

@@ -7,26 +7,35 @@ 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\Entity\ShortUrl;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options;
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 Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
{
private RedirectResponseHelperInterface $redirectResponseHelper;
public function __construct(
ShortUrlResolverInterface $urlResolver,
RequestTrackerInterface $requestTracker,
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
private RedirectResponseHelperInterface $redirectResponseHelper,
VisitsTrackerInterface $visitTracker,
Options\TrackingOptions $trackingOptions,
RedirectResponseHelperInterface $redirectResponseHelper,
?LoggerInterface $logger = null
) {
parent::__construct($urlResolver, $requestTracker);
parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger);
$this->redirectResponseHelper = $redirectResponseHelper;
}
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response
protected function createSuccessResp(string $longUrl): 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,8 +17,11 @@ use const PHP_EOL;
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
{
public function __construct(private CrawlingHelperInterface $crawlingHelper)
private CrawlingHelperInterface $crawlingHelper;
public function __construct(CrawlingHelperInterface $crawlingHelper)
{
$this->crawlingHelper = $crawlingHelper;
}
public function handle(ServerRequestInterface $request): ResponseInterface

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Domain extends AbstractEntity implements JsonSerializable
{
public function __construct(private string $authority)
private string $authority;
public function __construct(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,10 +10,12 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Tag extends AbstractEntity implements JsonSerializable
{
private string $name;
private Collections\Collection $shortUrls;
public function __construct(private string $name)
public function __construct(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) {
} catch (InvalidArgumentException $e) {
return null;
}
}

View File

@@ -13,24 +13,31 @@ use function rtrim;
class NotFoundType
{
private function __construct(private string $type)
private string $type;
private function __construct(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));
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
if ($routeResult->isFailure()) {
return new self(Visit::TYPE_REGULAR_404);
}
$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,
};
if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
return new self(Visit::TYPE_INVALID_SHORT_URL);
}
return new self($type);
return new self(self::class);
}
public function isBaseUrl(): bool

View File

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

View File

@@ -20,7 +20,6 @@ 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,17 +8,33 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
class NotFoundTrackerMiddleware implements MiddlewareInterface
{
public function __construct(private RequestTrackerInterface $requestTracker)
private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker)
{
$this->visitsTracker = $visitsTracker;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->requestTracker->trackNotFoundIfApplicable($request);
/** @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);
}
return $handler->handle($request);
}
}

View File

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

View File

@@ -8,11 +8,13 @@ use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
class CloseDbConnectionEventListener
{
private ReopeningEntityManagerInterface $em;
/** @var callable */
private $wrapped;
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(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,8 +8,11 @@ use JsonSerializable;
abstract class AbstractVisitEvent implements JsonSerializable
{
public function __construct(protected string $visitId)
protected string $visitId;
public function __construct(string $visitId)
{
$this->visitId = $visitId;
}
public function visitId(): string

View File

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

View File

@@ -19,13 +19,24 @@ use Throwable;
class LocateVisit
{
private IpLocationResolverInterface $ipLocationResolver;
private EntityManagerInterface $em;
private LoggerInterface $logger;
private DbUpdaterInterface $dbUpdater;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
private IpLocationResolverInterface $ipLocationResolver,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private DbUpdaterInterface $dbUpdater,
private EventDispatcherInterface $eventDispatcher
IpLocationResolverInterface $ipLocationResolver,
EntityManagerInterface $em,
LoggerInterface $logger,
DbUpdaterInterface $dbUpdater,
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,12 +17,21 @@ use function Functional\each;
class NotifyVisitToMercure
{
private HubInterface $hub;
private MercureUpdatesGeneratorInterface $updatesGenerator;
private EntityManagerInterface $em;
private LoggerInterface $logger;
public function __construct(
private HubInterface $hub,
private MercureUpdatesGeneratorInterface $updatesGenerator,
private EntityManagerInterface $em,
private LoggerInterface $logger
HubInterface $hub,
MercureUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger
) {
$this->hub = $hub;
$this->em = $em;
$this->logger = $logger;
$this->updatesGenerator = $updatesGenerator;
}
public function __invoke(VisitLocated $shortUrlLocated): void

View File

@@ -24,15 +24,28 @@ 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(
private ClientInterface $httpClient,
private EntityManagerInterface $em,
private LoggerInterface $logger,
/** @var string[] */
private array $webhooks,
private DataTransformerInterface $transformer,
private AppOptions $appOptions
ClientInterface $httpClient,
EntityManagerInterface $em,
LoggerInterface $logger,
array $webhooks,
DataTransformerInterface $transformer,
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,8 +12,13 @@ use function sprintf;
class UpdateGeoLiteDb
{
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater, private LoggerInterface $logger)
private GeolocationDbUpdaterInterface $dbUpdater;
private LoggerInterface $logger;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater, LoggerInterface $logger)
{
$this->dbUpdater = $dbUpdater;
$this->logger = $logger;
}
public function __invoke(): void

View File

@@ -20,14 +20,22 @@ 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(
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
private ShortCodeHelperInterface $shortCodeHelper,
private DoctrineBatchHelperInterface $batchHelper
EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper,
DoctrineBatchHelperInterface $batchHelper
) {
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
$this->batchHelper = $batchHelper;
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line
}
@@ -56,7 +64,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
try {
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
} catch (NonUniqueSlugException) {
} catch (NonUniqueSlugException $e) {
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
continue;
}
@@ -69,7 +77,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) {
@@ -88,7 +96,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,8 +14,13 @@ use function sprintf;
final class ShortUrlImporting
{
private function __construct(private ShortUrl $shortUrl, private bool $isNew)
private ShortUrl $shortUrl;
private bool $isNew;
private function __construct(ShortUrl $shortUrl, bool $isNew)
{
$this->shortUrl = $shortUrl;
$this->isNew = $isNew;
}
public static function fromExistingShortUrl(ShortUrl $shortUrl): self
@@ -38,7 +43,10 @@ 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?->gte(Chronos::instance($importedVisit->date()))) {
if (
$mostRecentImportedDate !== null
&& $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date()))
) {
continue;
}

View File

@@ -18,10 +18,15 @@ 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(
private DataTransformerInterface $shortUrlTransformer,
private DataTransformerInterface $orphanVisitTransformer
DataTransformerInterface $shortUrlTransformer,
DataTransformerInterface $orphanVisitTransformer
) {
$this->shortUrlTransformer = $shortUrlTransformer;
$this->orphanVisitTransformer = $orphanVisitTransformer;
}
public function newVisitUpdate(Visit $visit): Update

View File

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

View File

@@ -15,11 +15,11 @@ final class ShortUrlsParams
public const DEFAULT_ITEMS_PER_PAGE = 10;
private int $page;
private ?int $itemsPerPage = null;
private ?string $searchTerm;
private array $tags;
private ShortUrlsOrdering $orderBy;
private ?DateRange $dateRange;
private ?int $itemsPerPage = null;
private function __construct()
{

View File

@@ -14,16 +14,25 @@ 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,
private int $page = self::FIRST_PAGE,
int $page = self::FIRST_PAGE,
?int $itemsPerPage = null,
private bool $excludeBots = false
bool $excludeBots = false
) {
$this->dateRange = $dateRange ?? new DateRange();
$this->page = $this->determinePage($page);
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
$this->excludeBots = $excludeBots;
}
private function determinePage(int $page): int
{
return $page > 0 ? $page : self::FIRST_PAGE;
}
private function determineItemsPerPage(?int $itemsPerPage): int
@@ -39,7 +48,7 @@ final class VisitsParams
{
return new self(
parseDateRangeFromQuery($query, 'startDate', 'endDate'),
(int) ($query['page'] ?? 1),
(int) ($query['page'] ?? self::FIRST_PAGE),
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
isset($query['excludeBots']),
);

View File

@@ -19,7 +19,6 @@ 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
{
@@ -68,16 +67,6 @@ 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,8 +11,13 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params)
private VisitRepositoryInterface $repo;
private VisitsParams $params;
public function __construct(VisitRepositoryInterface $repo, VisitsParams $params)
{
$this->repo = $repo;
$this->params = $params;
}
protected function doCount(): int

View File

@@ -4,6 +4,7 @@ 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;
@@ -11,11 +12,15 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
{
public function __construct(
private ShortUrlRepositoryInterface $repository,
private ShortUrlsParams $params,
private ?ApiKey $apiKey
) {
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 getSlice($offset, $length): array // phpcs:ignore
@@ -27,7 +32,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->tags(),
$this->params->orderBy(),
$this->params->dateRange(),
$this->apiKey?->spec(),
$this->resolveSpec(),
);
}
@@ -37,7 +42,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->searchTerm(),
$this->params->tags(),
$this->params->dateRange(),
$this->apiKey?->spec(),
$this->resolveSpec(),
);
}
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec() : null;
}
}

View File

@@ -4,6 +4,7 @@ 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;
@@ -12,12 +13,21 @@ 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(
private VisitRepositoryInterface $visitRepository,
private string $tag,
private VisitsParams $params,
private ?ApiKey $apiKey
VisitRepositoryInterface $visitRepository,
string $tag,
VisitsParams $params,
?ApiKey $apiKey
) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->tag = $tag;
$this->apiKey = $apiKey;
}
public function getSlice($offset, $length): array // phpcs:ignore
@@ -27,7 +37,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsListFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
$this->apiKey?->spec(true),
$this->resolveSpec(),
$length,
$offset,
),
@@ -41,8 +51,13 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsCountFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
$this->apiKey?->spec(true),
$this->resolveSpec(),
),
);
}
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec(true) : null;
}
}

View File

@@ -13,12 +13,21 @@ 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(
private VisitRepositoryInterface $visitRepository,
private ShortUrlIdentifier $identifier,
private VisitsParams $params,
private ?Specification $spec
VisitRepositoryInterface $visitRepository,
ShortUrlIdentifier $identifier,
VisitsParams $params,
?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?->hasOrderField()) {
if ($orderBy !== null && $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?->getStartDate() !== null) {
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange?->getEndDate() !== null) {
if ($dateRange !== null && $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
/** @var Visit|null $visit */
$lastId = $visit?->getId() ?? $lastId;
$lastId = isset($visit) ? $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?->getId() ?? '-1';
$shortUrlId = $shortUrl !== null ? $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?->getStartDate() !== null) {
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
}
if ($dateRange?->getEndDate() !== null) {
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
}
}

View File

@@ -13,11 +13,18 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
private EntityManagerInterface $em;
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
private ShortUrlResolverInterface $urlResolver;
public function __construct(
private EntityManagerInterface $em,
private DeleteShortUrlsOptions $deleteShortUrlsOptions,
private ShortUrlResolverInterface $urlResolver
EntityManagerInterface $em,
DeleteShortUrlsOptions $deleteShortUrlsOptions,
ShortUrlResolverInterface $urlResolver
) {
$this->em = $em;
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
$this->urlResolver = $urlResolver;
}
/**
@@ -27,7 +34,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,8 +11,11 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper
{
public function __construct(private EntityManagerInterface $em)
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool

View File

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

View File

@@ -21,12 +21,21 @@ 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(
private ORM\EntityManagerInterface $em,
private ShortUrlResolverInterface $urlResolver,
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private ShortUrlRelationResolverInterface $relationResolver
ORM\EntityManagerInterface $em,
ShortUrlResolverInterface $urlResolver,
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
ShortUrlRelationResolverInterface $relationResolver
) {
$this->em = $em;
$this->urlResolver = $urlResolver;
$this->titleResolutionHelper = $titleResolutionHelper;
$this->relationResolver = $relationResolver;
}
/**
@@ -50,7 +59,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,12 +16,21 @@ 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(
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
private ShortCodeHelperInterface $shortCodeHelper
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper
) {
$this->titleResolutionHelper = $titleResolutionHelper;
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
}
/**
@@ -69,7 +78,7 @@ class UrlShortener implements UrlShortenerInterface
if (! $couldBeMadeUnique) {
$domain = $shortUrlToBeCreated->getDomain();
$domainAuthority = $domain?->getAuthority();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
}

View File

@@ -1,50 +0,0 @@
<?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

@@ -1,12 +0,0 @@
<?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,8 +11,13 @@ use function sprintf;
class ShortUrlStringifier implements ShortUrlStringifierInterface
{
public function __construct(private array $domainConfig, private string $basePath = '')
private array $domainConfig;
private string $basePath;
public function __construct(array $domainConfig, string $basePath = '')
{
$this->domainConfig = $domainConfig;
$this->basePath = $basePath;
}
public function stringify(ShortUrl $shortUrl): string

View File

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

View File

@@ -1,73 +0,0 @@
<?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)];
}
}

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