From 7b04016ca227aa16717684530cd8b74f20480d63 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 24 May 2023 08:59:21 +0200 Subject: [PATCH 01/77] Fix version number on JamesIves/github-pages-deploy-action GitHub action --- .github/workflows/publish-swagger-spec.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index f7be6502..06b33566 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -26,7 +26,7 @@ jobs: - run: mkdir ${{ steps.determine_version.outputs.version }} - run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json - name: Publish spec - uses: JamesIves/github-pages-deploy-action@4 + uses: JamesIves/github-pages-deploy-action@4.4.1 with: token: ${{ secrets.OAS_PUBLISH_TOKEN }} repository-name: 'shlinkio/shlink-open-api-specs' From 07ce5f05a2716a2239f0e35eb3b76b1725395caf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 29 May 2023 09:02:59 +0200 Subject: [PATCH 02/77] Add missing entry to v3.6.0 changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc43101..bd4c68b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * Versions with `-openswoole` suffix (like `3.6.0-openswoole`) will always use openswoole as the runtime, even if default one changes in the future. ### Deprecated -* *Nothing* +* Deprecated `ENABLE_PERIODIC_VISIT_LOCATE` env var. Use an external mechanism to automate visit locations. ### Removed * *Nothing* From ee6a8ede0ab8245015708c496cf85586661d6a00 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 29 May 2023 09:43:12 +0200 Subject: [PATCH 03/77] Drop support for PHP 8.1 --- .github/workflows/ci-db-tests.yml | 4 ++-- .github/workflows/ci-mutation-tests.yml | 2 +- .github/workflows/ci-tests.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish-release.yml | 2 +- .github/workflows/publish-swagger-spec.yml | 2 +- CHANGELOG.md | 17 +++++++++++++++++ README.md | 4 ++-- composer.json | 2 +- data/infra/examples/nginx-vhost.conf | 2 +- .../test-cli/Command/ImportShortUrlsTest.php | 6 +----- .../Rest/test-api/Action/ListShortUrlsTest.php | 1 - 12 files changed, 32 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index 0c77ded6..f0fc3eaa 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1', '8.2'] + php-version: ['8.2'] env: LC_ALL: C steps: @@ -36,7 +36,7 @@ jobs: run: composer test:db:${{ inputs.platform }} - name: Upload code coverage uses: actions/upload-artifact@v3 - if: ${{ matrix.php-version == '8.1' && inputs.platform == 'sqlite:ci' }} + if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }} with: name: coverage-db path: | diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index 20e5aefa..a0facf09 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1', '8.2'] + php-version: ['8.2'] steps: - uses: actions/checkout@v3 - uses: './.github/actions/ci-setup' diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 043a3824..e407da70 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1', '8.2'] + php-version: ['8.2'] steps: - uses: actions/checkout@v3 - name: Start postgres database server @@ -29,7 +29,7 @@ jobs: extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v3 - if: ${{ matrix.php-version == '8.1' }} + if: ${{ matrix.php-version == '8.2' }} with: name: coverage-${{ inputs.test-group }} path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdc5a025..4f19904d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1'] + php-version: ['8.2'] command: ['cs', 'stan', 'swagger:validate'] steps: - uses: actions/checkout@v3 @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1', '8.2'] + php-version: ['8.2'] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: @@ -135,7 +135,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1'] + php-version: ['8.2'] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bda463a9..66c24528 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1', '8.2'] + php-version: ['8.2'] swoole: ['yes', 'no'] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 06b33566..225a9836 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1'] + php-version: ['8.2'] steps: - uses: actions/checkout@v3 - name: Determine version diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4c68b6..6c967cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* [#1790](https://github.com/shlinkio/shlink/issues/1790) Drop support for PHP 8.1. + +### Fixed +* *Nothing* + + ## [3.6.0] - 2023-05-24 ### Added * [#1148](https://github.com/shlinkio/shlink/issues/1148) Add support to delete short URL visits. diff --git a/README.md b/README.md index e86c5156..c01e343c 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ You can learn how to use the official docker image by reading [the docs](https:/ The idea is that you can just generate a container using the image and provide the custom config via env vars. -## Self hosted +## Self-hosted First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 8.1 or 8.2 +* PHP 8.2 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * apcu extension is recommended if you don't plan to use openswoole. * xml extension is required if you want to generate QR codes in svg format. diff --git a/composer.json b/composer.json index a3494086..63196aa7 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", "ext-curl": "*", "ext-gd": "*", "ext-json": "*", diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 6cd4dd4e..0cd3ff4b 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -11,7 +11,7 @@ server { location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/module/CLI/test-cli/Command/ImportShortUrlsTest.php b/module/CLI/test-cli/Command/ImportShortUrlsTest.php index 3a710af0..1ed15d7c 100644 --- a/module/CLI/test-cli/Command/ImportShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ImportShortUrlsTest.php @@ -19,11 +19,7 @@ use function unlink; class ImportShortUrlsTest extends CliTestCase { - /** - * @var false|string|null - * @todo Use native type once PHP 8.1 support is dropped - */ - private mixed $tempCsvFile = null; + private false|string|null $tempCsvFile = null; protected function setUp(): void { diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 6c599a4f..bb6296f7 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -169,7 +169,6 @@ class ListShortUrlsTest extends ApiTestCase public static function provideFilteredLists(): iterable { - // FIXME Cannot use enums in constants in PHP 8.1. Change this once support for PHP 8.1 is dropped $withDeviceLongUrls = static fn (array $shortUrl, ?array $longUrls = null) => [ ...$shortUrl, 'deviceLongUrls' => $longUrls ?? [ From 8b03532ddb72d1bb2838d26e43e1dd4112f9fe40 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 30 May 2023 09:12:46 +0200 Subject: [PATCH 04/77] Add ORPHAN_VISITS_EXCLUDED API key role --- module/CLI/src/ApiKey/RoleResolver.php | 4 ++++ .../src/Command/Api/GenerateKeyCommand.php | 15 +++++++++++--- .../CLI/src/Command/Api/ListKeysCommand.php | 7 ++----- .../Domain/Repository/DomainRepository.php | 1 + .../Core/src/Tag/Repository/TagRepository.php | 1 + .../Shlinkio.Shlink.Rest.Entity.ApiKey.php | 2 +- ...Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php | 2 +- .../Rest/src/ApiKey/Model/RoleDefinition.php | 5 +++++ module/Rest/src/ApiKey/Role.php | 11 ++++++++-- module/Rest/src/Entity/ApiKeyRole.php | 20 ++++++++++++------- module/Rest/test/ApiKey/RoleTest.php | 9 +++++---- 11 files changed, 54 insertions(+), 23 deletions(-) diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index c1ae8f05..5a60120f 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -22,6 +22,7 @@ class RoleResolver implements RoleResolverInterface { $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); + $noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName()); $roleDefinitions = []; if ($author) { @@ -30,6 +31,9 @@ class RoleResolver implements RoleResolverInterface if (is_string($domainAuthority)) { $roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority); } + if ($noOrphanVisits) { + $roleDefinitions[] = RoleDefinition::forOrphanVisitsExcluded(); + } return $roleDefinitions; } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index c2d6cf10..ab4b5d54 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -25,8 +25,8 @@ class GenerateKeyCommand extends Command public const NAME = 'api-key:generate'; public function __construct( - private ApiKeyServiceInterface $apiKeyService, - private RoleResolverInterface $roleResolver, + private readonly ApiKeyServiceInterface $apiKeyService, + private readonly RoleResolverInterface $roleResolver, ) { parent::__construct(); } @@ -35,6 +35,8 @@ class GenerateKeyCommand extends Command { $authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); $domainOnly = Role::DOMAIN_SPECIFIC->paramName(); + $noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName(); + $help = <<%command.name% generates a new valid API key. @@ -52,7 +54,8 @@ class GenerateKeyCommand extends Command * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly} * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com - * Both: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com + * Cannot see orphan visits: %command.full_name% --{$noOrphanVisits} + * All: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits} HELP; $this @@ -85,6 +88,12 @@ class GenerateKeyCommand extends Command Role::DOMAIN_SPECIFIC->value, ), ) + ->addOption( + $noOrphanVisits, + 'o', + InputOption::VALUE_NONE, + sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value), + ) ->setHelp($help); } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 87b239b7..4fd4b005 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -27,7 +27,7 @@ class ListKeysCommand extends Command public const NAME = 'api-key:list'; - public function __construct(private ApiKeyServiceInterface $apiKeyService) + public function __construct(private readonly ApiKeyServiceInterface $apiKeyService) { parent::__construct(); } @@ -60,10 +60,7 @@ class ListKeysCommand extends Command } $rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles( - fn (Role $role, array $meta) => - empty($meta) - ? $role->toFriendlyName() - : sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)), + fn (Role $role, array $meta) => $role->toFriendlyName($meta), )); return $rowData; diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index dcbc3d9e..aae44aed 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -79,6 +79,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) { Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))], Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)], + default => null, }) ?? []; } } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 68e5df4b..adfb6480 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -56,6 +56,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito Role::AUTHORED_SHORT_URLS => $qb->andWhere( $qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), + default => $qb, }); // For admins and when no API key is present, we'll return tags which are not linked to any short URL diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index 1e0b041b..7d0f3583 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -44,7 +44,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createOneToMany('roles', ApiKeyRole::class) ->mappedBy('apiKey') - ->setIndexBy('roleName') + ->setIndexBy('role') ->cascadePersist() ->orphanRemoval() ->build(); diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php index 8df324a4..04d1cf9d 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php @@ -25,7 +25,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); (new FieldBuilder($builder, [ - 'fieldName' => 'roleName', + 'fieldName' => 'role', 'type' => Types::STRING, 'enumType' => Role::class, ]))->columnName('role_name') diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 403e6214..819a224c 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -25,4 +25,9 @@ final class RoleDefinition ['domain_id' => $domain->getId(), 'authority' => $domain->authority], ); } + + public static function forOrphanVisitsExcluded(): self + { + return new self(Role::NO_ORPHAN_VISITS, []); + } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 5a4edb81..dd2d8ae7 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -12,16 +12,20 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; +use function sprintf; + enum Role: string { case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; + case NO_ORPHAN_VISITS = 'NO_ORPHAN_VISITS'; - public function toFriendlyName(): string + public function toFriendlyName(array $meta): string { return match ($this) { self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', + self::DOMAIN_SPECIFIC => sprintf('Domain only: %s', Role::domainAuthorityFromMeta($meta)), + self::NO_ORPHAN_VISITS => 'No orphan visits', }; } @@ -30,6 +34,7 @@ enum Role: string return match ($this) { self::AUTHORED_SHORT_URLS => 'author-only', self::DOMAIN_SPECIFIC => 'domain-only', + self::NO_ORPHAN_VISITS => 'no-orphan-visits', }; } @@ -38,6 +43,7 @@ enum Role: string return match ($role->role()) { self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context), self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context), + default => Spec::andX(), }; } @@ -46,6 +52,7 @@ enum Role: string return match ($role->role()) { self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())), self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))), + default => Spec::andX(), }; } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 8491cfce..6fadb839 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -9,13 +9,24 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKeyRole extends AbstractEntity { - public function __construct(private Role $roleName, private array $meta, private ApiKey $apiKey) + public function __construct(public readonly Role $role, private array $meta, public readonly ApiKey $apiKey) { } + /** + * @deprecated Use property access directly + */ public function role(): Role { - return $this->roleName; + return $this->role; + } + + /** + * @deprecated Use property access directly + */ + public function apiKey(): ApiKey + { + return $this->apiKey; } public function meta(): array @@ -27,9 +38,4 @@ class ApiKeyRole extends AbstractEntity { $this->meta = $newMeta; } - - public function apiKey(): ApiKey - { - return $this->apiKey; - } } diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index b572630b..bf02318a 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -86,14 +86,15 @@ class RoleTest extends TestCase } #[Test, DataProvider('provideRoleNames')] - public function getsExpectedRoleFriendlyName(Role $role, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $role, array $meta, string $expectedFriendlyName): void { - self::assertEquals($expectedFriendlyName, $role->toFriendlyName()); + self::assertEquals($expectedFriendlyName, $role->toFriendlyName($meta)); } public static function provideRoleNames(): iterable { - yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, 'Author only']; - yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, 'Domain only']; + yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, [], 'Author only']; + yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, ['authority' => 's.test'], 'Domain only: s.test']; + yield Role::NO_ORPHAN_VISITS->value => [Role::NO_ORPHAN_VISITS, [], 'No orphan visits']; } } From 12da04ef37e6b1550f9548408d92ea40f0438141 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 30 May 2023 09:32:44 +0200 Subject: [PATCH 05/77] Add ApiKey check to tell if it has any role that is short-url restrictive --- module/Core/src/Tag/Repository/TagRepository.php | 4 ++-- module/Core/src/Tag/TagService.php | 4 ++-- .../ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php | 2 +- module/Rest/src/Entity/ApiKey.php | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index adfb6480..278dbe8b 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -59,8 +59,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito default => $qb, }); - // For admins and when no API key is present, we'll return tags which are not linked to any short URL - $joiningMethod = ApiKey::isAdmin($apiKey) ? 'leftJoin' : 'join'; + // For non-restricted API keys, we'll return tags which are not linked to any short URL + $joiningMethod = ! ApiKey::isShortUrlRestricted($apiKey) ? 'leftJoin' : 'join'; $tagsSubQb = $conn->createQueryBuilder(); $tagsSubQb ->select('t.id AS tag_id', 't.name AS tag', 'COUNT(DISTINCT s.id) AS short_urls_count') diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index ea9a4e8b..36fd0514 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -59,7 +59,7 @@ class TagService implements TagServiceInterface */ public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void { - if (! ApiKey::isAdmin($apiKey)) { + if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forDeletion(); } @@ -75,7 +75,7 @@ class TagService implements TagServiceInterface */ public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag { - if (! ApiKey::isAdmin($apiKey)) { + if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forRenaming(); } diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 9a8f8056..122829ed 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -18,7 +18,7 @@ class WithApiKeySpecsEnsuringJoin extends BaseSpecification protected function getSpec(): Specification { - return $this->apiKey === null || ApiKey::isAdmin($this->apiKey) ? Spec::andX() : Spec::andX( + return $this->apiKey === null || ! ApiKey::isShortUrlRestricted($this->apiKey) ? Spec::andX() : Spec::andX( Spec::join($this->fieldToJoin, 's'), $this->apiKey->spec($this->fieldToJoin), ); diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 88cfa27e..72977c86 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -122,6 +122,21 @@ class ApiKey extends AbstractEntity return $apiKey === null || $apiKey->roles->isEmpty(); } + /** + * Tells if provided API key has any of the roles restricting at the short URL level + */ + public static function isShortUrlRestricted(?ApiKey $apiKey): bool + { + if ($apiKey === null) { + return false; + } + + return ( + $apiKey->roles->containsKey(Role::AUTHORED_SHORT_URLS->value) + || $apiKey->roles->containsKey(Role::DOMAIN_SPECIFIC->value) + ); + } + public function hasRole(Role $role): bool { return $this->roles->containsKey($role->value); From eaba5edf7f520ea49bc1497d40c228dea499e746 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 31 May 2023 09:11:20 +0200 Subject: [PATCH 06/77] Restrict interaction with orphan visits when API key has that role --- .../Adapter/OrphanVisitsPaginatorAdapter.php | 10 ++++++++-- .../src/Visit/Repository/VisitRepository.php | 9 +++++++++ module/Core/src/Visit/VisitsDeleter.php | 5 +++-- module/Core/src/Visit/VisitsStatsHelper.php | 10 ++++++---- .../src/Visit/VisitsStatsHelperInterface.php | 2 +- .../Visit/Repository/VisitRepositoryTest.php | 8 ++++++++ .../OrphanVisitsPaginatorAdapterTest.php | 18 +++++++++++++----- module/Core/test/Visit/VisitsDeleterTest.php | 15 +++++++++++++++ .../Core/test/Visit/VisitsStatsHelperTest.php | 12 ++++++++---- .../src/Action/Visit/OrphanVisitsAction.php | 4 +++- .../Action/Visit/OrphanVisitsActionTest.php | 5 ++++- 11 files changed, 78 insertions(+), 20 deletions(-) diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 4e6e4daf..e871d125 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -9,11 +9,15 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - public function __construct(private readonly VisitRepositoryInterface $repo, private readonly VisitsParams $params) - { + public function __construct( + private readonly VisitRepositoryInterface $repo, + private readonly VisitsParams $params, + private readonly ?ApiKey $apiKey, + ) { } protected function doCount(): int @@ -21,6 +25,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte return $this->repo->countOrphanVisits(new VisitsCountFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, + apiKey: $this->apiKey, )); } @@ -29,6 +34,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte return $this->repo->findOrphanVisits(new VisitsListFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, + apiKey: $this->apiKey, limit: $length, offset: $offset, )); diff --git a/module/Core/src/Visit/Repository/VisitRepository.php b/module/Core/src/Visit/Repository/VisitRepository.php index 7021e70b..dc54057c 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Spec\CountOfNonOrphanVisits; use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits; +use Shlinkio\Shlink\Rest\ApiKey\Role; use const PHP_INT_MAX; @@ -139,6 +140,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findOrphanVisits(VisitsListFiltering $filtering): array { + if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { + return []; + } + $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNull('v.shortUrl')); return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); @@ -146,6 +151,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function countOrphanVisits(VisitsCountFiltering $filtering): int { + if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { + return 0; + } + return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering)); } diff --git a/module/Core/src/Visit/VisitsDeleter.php b/module/Core/src/Visit/VisitsDeleter.php index 5a8adca5..2b925e17 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Model\BulkDeleteResult; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsDeleter implements VisitsDeleterInterface @@ -16,7 +17,7 @@ class VisitsDeleter implements VisitsDeleterInterface public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult { - // TODO Check API key has permissions for orphan visits - return new BulkDeleteResult($this->repository->deleteOrphanVisits()); + $affectedItems = $apiKey?->hasRole(Role::NO_ORPHAN_VISITS) ? 0 : $this->repository->deleteOrphanVisits(); + return new BulkDeleteResult($affectedItems); } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 25f44921..bdd2fd3b 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -43,11 +43,13 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface return new VisitsStats( nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), - orphanVisitsTotal: $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + orphanVisitsTotal: $visitsRepo->countOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), - orphanVisitsNonBots: $visitsRepo->countOrphanVisits(new VisitsCountFiltering(excludeBots: true)), + orphanVisitsNonBots: $visitsRepo->countOrphanVisits( + new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), + ), ); } @@ -114,12 +116,12 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params): Paginator + public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params), $params); + return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); } public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index af6cb77c..71173553 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -43,7 +43,7 @@ interface VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params): Paginator; + public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @return Visit[]|Paginator diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 6eb2fe4e..d8e80e03 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -262,6 +262,9 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forOrphanVisitsExcluded())); + $this->getEntityManager()->persist($noOrphanVisitsApiKey); + $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::create( @@ -305,6 +308,7 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1))); self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2))); self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($domainApiKey))); + self::assertEquals(0, $this->repo->countOrphanVisits(VisitsCountFiltering::withApiKey($noOrphanVisitsApiKey))); self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); @@ -326,6 +330,9 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); + $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forOrphanVisitsExcluded())); + $this->getEntityManager()->persist($noOrphanVisitsApiKey); + $botsCount = 3; for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( @@ -346,6 +353,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + self::assertCount(0, $this->repo->findOrphanVisits(new VisitsListFiltering(apiKey: $noOrphanVisitsApiKey))); self::assertCount(18, $this->repo->findOrphanVisits(new VisitsListFiltering())); self::assertCount(15, $this->repo->findOrphanVisits(new VisitsListFiltering(null, true))); self::assertCount(5, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 5))); diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 6a367ed7..04e3f84c 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -15,18 +15,22 @@ use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class OrphanVisitsPaginatorAdapterTest extends TestCase { private OrphanVisitsPaginatorAdapter $adapter; private MockObject & VisitRepositoryInterface $repo; private VisitsParams $params; + private ApiKey $apiKey; protected function setUp(): void { $this->repo = $this->createMock(VisitRepositoryInterface::class); $this->params = VisitsParams::fromRawData([]); - $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params); + $this->apiKey = ApiKey::create(); + + $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); } #[Test] @@ -34,7 +38,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $this->repo->expects($this->once())->method('countOrphanVisits')->with( - new VisitsCountFiltering($this->params->dateRange), + new VisitsCountFiltering($this->params->dateRange, apiKey: $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -51,9 +55,13 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $this->repo->expects($this->once())->method('findOrphanVisits')->with( - new VisitsListFiltering($this->params->dateRange, $this->params->excludeBots, null, $limit, $offset), - )->willReturn($list); + $this->repo->expects($this->once())->method('findOrphanVisits')->with(new VisitsListFiltering( + $this->params->dateRange, + $this->params->excludeBots, + $this->apiKey, + $limit, + $offset, + ))->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Visit/VisitsDeleterTest.php b/module/Core/test/Visit/VisitsDeleterTest.php index 155d0725..eddc7767 100644 --- a/module/Core/test/Visit/VisitsDeleterTest.php +++ b/module/Core/test/Visit/VisitsDeleterTest.php @@ -10,6 +10,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; use Shlinkio\Shlink\Core\Visit\VisitsDeleter; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsDeleterTest extends TestCase { @@ -38,4 +41,16 @@ class VisitsDeleterTest extends TestCase yield '5000' => [5000]; yield '0' => [0]; } + + #[Test] + public function returnsNoDeletedVisitsForApiKeyWithNoPermission(): void + { + $this->repo->expects($this->never())->method('deleteOrphanVisits'); + + $result = $this->visitsDeleter->deleteOrphanVisits( + ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forOrphanVisitsExcluded())), + ); + + self::assertEquals(0, $result->affectedItems); + } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 18954a22..3fc024df 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -50,13 +50,14 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProvider('provideCounts')] - public function returnsExpectedVisitsStats(int $expectedCount): void + public function returnsExpectedVisitsStats(int $expectedCount, ?ApiKey $apiKey): void { $repo = $this->createMock(VisitRepository::class); $callCount = 0; $repo->expects($this->exactly(2))->method('countNonOrphanVisits')->willReturnCallback( - function (VisitsCountFiltering $options) use ($expectedCount, &$callCount) { + function (VisitsCountFiltering $options) use ($expectedCount, $apiKey, &$callCount) { Assert::assertEquals($callCount !== 0, $options->excludeBots); + Assert::assertEquals($apiKey, $options->apiKey); $callCount++; return $expectedCount * 3; @@ -67,14 +68,17 @@ class VisitsStatsHelperTest extends TestCase )->willReturn($expectedCount); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); - $stats = $this->helper->getVisitsStats(); + $stats = $this->helper->getVisitsStats($apiKey); self::assertEquals(new VisitsStats($expectedCount * 3, $expectedCount), $stats); } public static function provideCounts(): iterable { - return map(range(0, 50, 5), fn (int $value) => [$value]); + return [ + ...map(range(0, 50, 5), fn (int $value) => [$value, null]), + ...map(range(0, 18, 3), fn (int $value) => [$value, ApiKey::create()]), + ]; } #[Test, DataProvider('provideAdminApiKeys')] diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index af5292a2..c7adf3a1 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class OrphanVisitsAction extends AbstractRestAction { @@ -29,7 +30,8 @@ class OrphanVisitsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { $params = VisitsParams::fromRawData($request->getQueryParams()); - $visits = $this->visitsHelper->orphanVisits($params); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsHelper->orphanVisits($params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits, $this->orphanVisitTransformer), diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index f4a22caa..da660d0e 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function count; @@ -48,7 +49,9 @@ class OrphanVisitsActionTest extends TestCase )->willReturn([]); /** @var JsonResponse $response */ - $response = $this->action->handle(ServerRequestFactory::fromGlobals()); + $response = $this->action->handle( + ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()), + ); $payload = $response->getPayload(); self::assertCount($visitsAmount, $payload['visits']['data']); From be26dd58c3286d398241939488b006a73fb74d04 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 31 May 2023 09:22:40 +0200 Subject: [PATCH 07/77] Add API tests to cover usage of orphan visits restricted keys --- module/CLI/src/ApiKey/RoleResolver.php | 2 +- .../Visit/Repository/VisitRepositoryTest.php | 4 ++-- module/Core/test/Visit/VisitsDeleterTest.php | 2 +- .../Rest/src/ApiKey/Model/RoleDefinition.php | 2 +- .../test-api/Action/DeleteOrphanVisitsTest.php | 17 ++++++++++++++++- .../Rest/test-api/Action/GlobalVisitsTest.php | 11 ++++++----- .../Rest/test-api/Action/OrphanVisitsTest.php | 12 ++++++++++++ .../Rest/test-api/Fixtures/ApiKeyFixture.php | 18 +++++++++++++----- 8 files changed, 52 insertions(+), 16 deletions(-) diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index 5a60120f..4df10bf4 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -32,7 +32,7 @@ class RoleResolver implements RoleResolverInterface $roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority); } if ($noOrphanVisits) { - $roleDefinitions[] = RoleDefinition::forOrphanVisitsExcluded(); + $roleDefinitions[] = RoleDefinition::forNoOrphanVisits(); } return $roleDefinitions; diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index d8e80e03..cca71a14 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -262,7 +262,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forOrphanVisitsExcluded())); + $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forNoOrphanVisits())); $this->getEntityManager()->persist($noOrphanVisitsApiKey); $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); @@ -330,7 +330,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); - $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forOrphanVisitsExcluded())); + $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forNoOrphanVisits())); $this->getEntityManager()->persist($noOrphanVisitsApiKey); $botsCount = 3; diff --git a/module/Core/test/Visit/VisitsDeleterTest.php b/module/Core/test/Visit/VisitsDeleterTest.php index eddc7767..e47706a9 100644 --- a/module/Core/test/Visit/VisitsDeleterTest.php +++ b/module/Core/test/Visit/VisitsDeleterTest.php @@ -48,7 +48,7 @@ class VisitsDeleterTest extends TestCase $this->repo->expects($this->never())->method('deleteOrphanVisits'); $result = $this->visitsDeleter->deleteOrphanVisits( - ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forOrphanVisitsExcluded())), + ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forNoOrphanVisits())), ); self::assertEquals(0, $result->affectedItems); diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 819a224c..a35e904c 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -26,7 +26,7 @@ final class RoleDefinition ); } - public static function forOrphanVisitsExcluded(): self + public static function forNoOrphanVisits(): self { return new self(Role::NO_ORPHAN_VISITS, []); } diff --git a/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php b/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php index b7cf59b9..63a2f165 100644 --- a/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php +++ b/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class DeleteOrphanVisitsTest extends ApiTestCase { #[Test] - public function deletesVisitsForShortUrlWithoutAffectingTheRest(): void + public function deletesOrphanVisitsWithoutAffectingTheRest(): void { self::assertEquals(7, $this->getTotalVisits()); self::assertEquals(3, $this->getOrphanVisits()); @@ -24,6 +24,21 @@ class DeleteOrphanVisitsTest extends ApiTestCase self::assertEquals(0, $this->getOrphanVisits()); } + #[Test] + public function doesNotDeleteOrphanVisitsForRestrictedApiKey(): void + { + self::assertEquals(7, $this->getTotalVisits()); + self::assertEquals(3, $this->getOrphanVisits()); + + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/visits/orphan', apiKey: 'no_orphans_api_key'); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(200, $resp->getStatusCode()); + self::assertEquals(0, $payload['deletedVisits']); + self::assertEquals(7, $this->getTotalVisits()); // This verifies that regular visits have not been affected + self::assertEquals(3, $this->getOrphanVisits()); // This verifies that all orphan visits still exist + } + private function getTotalVisits(): int { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/non-orphan'); diff --git a/module/Rest/test-api/Action/GlobalVisitsTest.php b/module/Rest/test-api/Action/GlobalVisitsTest.php index 50591a14..657f16a6 100644 --- a/module/Rest/test-api/Action/GlobalVisitsTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsTest.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class GlobalVisitsTest extends ApiTestCase { #[Test, DataProvider('provideApiKeys')] - public function returnsExpectedVisitsStats(string $apiKey, int $expectedVisits): void + public function returnsExpectedVisitsStats(string $apiKey, int $expectedVisits, int $expectedOrphanVisits): void { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits', [], $apiKey); $payload = $this->getJsonResponsePayload($resp); @@ -20,13 +20,14 @@ class GlobalVisitsTest extends ApiTestCase self::assertArrayHasKey('visitsCount', $payload['visits']); self::assertArrayHasKey('orphanVisitsCount', $payload['visits']); self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); - self::assertEquals(3, $payload['visits']['orphanVisitsCount']); + self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisitsCount']); } public static function provideApiKeys(): iterable { - yield 'admin API key' => ['valid_api_key', 7]; - yield 'domain API key' => ['domain_api_key', 0]; - yield 'author API key' => ['author_api_key', 5]; + yield 'admin API key' => ['valid_api_key', 7, 3]; + yield 'domain API key' => ['domain_api_key', 0, 3]; + yield 'author API key' => ['author_api_key', 5, 3]; + yield 'no orphans API key' => ['no_orphans_api_key', 7, 0]; } } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 2049d80d..2c8b2479 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -69,4 +69,16 @@ class OrphanVisitsTest extends ApiTestCase [self::REGULAR_NOT_FOUND], ]; } + + #[Test] + public function noVisitsAreReturnedForRestrictedApiKey(): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', apiKey: 'no_orphans_api_key'); + $payload = $this->getJsonResponsePayload($resp); + $visits = $payload['visits']['data'] ?? null; + + self::assertIsArray($visits); + self::assertEmpty($visits); + self::assertEquals(0, $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS); + } } diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 5ac886ce..ef971d63 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -23,21 +23,29 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface public function load(ObjectManager $manager): void { - $manager->persist($this->buildApiKey('valid_api_key', true)); - $manager->persist($this->buildApiKey('disabled_api_key', false)); - $manager->persist($this->buildApiKey('expired_api_key', true, Chronos::now()->subDay()->startOfDay())); + $manager->persist($this->buildApiKey('valid_api_key', enabled: true)); + $manager->persist($this->buildApiKey('disabled_api_key', enabled: false)); + $manager->persist($this->buildApiKey( + 'expired_api_key', + enabled: true, + expiresAt: Chronos::now()->subDay()->startOfDay(), + )); - $authorApiKey = $this->buildApiKey('author_api_key', true); + $authorApiKey = $this->buildApiKey('author_api_key', enabled: true); $authorApiKey->registerRole(RoleDefinition::forAuthoredShortUrls()); $manager->persist($authorApiKey); $this->addReference('author_api_key', $authorApiKey); /** @var Domain $exampleDomain */ $exampleDomain = $this->getReference('example_domain'); - $domainApiKey = $this->buildApiKey('domain_api_key', true); + $domainApiKey = $this->buildApiKey('domain_api_key', enabled: true); $domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain)); $manager->persist($domainApiKey); + $authorApiKey = $this->buildApiKey('no_orphans_api_key', enabled: true); + $authorApiKey->registerRole(RoleDefinition::forNoOrphanVisits()); + $manager->persist($authorApiKey); + $manager->flush(); } From ea96a00b12c90618c23de0c17c2fae985acae41e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 31 May 2023 09:24:23 +0200 Subject: [PATCH 08/77] Update changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c967cdf..204486f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role. + + Keys with this role will always get `0` when fetching orphan visits. + + When trying to delete orphan visits the result will also be `0` and no visits will actually get deleted. ### Changed * *Nothing* From 8c94452348da3abb2b5786ae5abc9df5b39acb75 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 31 May 2023 09:33:05 +0200 Subject: [PATCH 09/77] Fix CLI tests --- .../CLI/test-cli/Command/ListApiKeysTest.php | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php index f8781d54..633cf819 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -26,34 +26,38 @@ class ListApiKeysTest extends CliTestCase { $expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString(); $enabledOnlyOutput = << [[], << [['-e'], $enabledOnlyOutput]; From 4013ae87dd05f3e05e1bde4914d0634aaf7a84cb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 1 Jun 2023 19:27:04 +0200 Subject: [PATCH 10/77] Change order to create initial database to avoid permission errors --- .../src/Command/Db/CreateDatabaseCommand.php | 51 +++++++++---------- .../Command/Db/CreateDatabaseCommandTest.php | 33 +++++------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index f6df9b04..0fd1b18c 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; +use Throwable; use function Functional\contains; use function Functional\map; @@ -53,9 +54,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand { $io = new SymfonyStyle($input, $output); - $this->checkDbExists(); - - if ($this->schemaExists()) { + if ($this->databaseTablesExist()) { $io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.'); return ExitCode::EXIT_SUCCESS; } @@ -68,30 +67,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand return ExitCode::EXIT_SUCCESS; } - private function checkDbExists(): void + private function databaseTablesExist(): bool { - if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) { - return; - } - - // In order to create the new database, we have to use a connection where the dbname was not set. - // Otherwise, it will fail to connect and will not be able to create the new database - $schemaManager = $this->noDbNameConn->createSchemaManager(); - $databases = $schemaManager->listDatabases(); - // We cannot use getDatabase() to get the database name here, because then the driver will try to connect, and - // it does not exist yet. We need to read from the raw params instead. - $shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null; - - if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) { - $schemaManager->createDatabase($shlinkDatabase); - } - } - - private function schemaExists(): bool - { - $schemaManager = $this->regularConn->createSchemaManager(); - $existingTables = $schemaManager->listTableNames(); - + $existingTables = $this->ensureDatabaseExistsAndGetTables(); $allMetadata = $this->em->getMetadataFactory()->getAllMetadata(); $shlinkTables = map($allMetadata, static fn (ClassMetadata $metadata) => $metadata->getTableName()); @@ -99,4 +77,25 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand // Any other inconsistency will be taken care of by the migrations. return some($shlinkTables, static fn (string $shlinkTable) => contains($existingTables, $shlinkTable)); } + + private function ensureDatabaseExistsAndGetTables(): array + { + if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) { + return []; + } + + try { + // Trying to list tables requires opening a connection to configured database. + // If it fails, it means it does not exist yet. + return $this->regularConn->createSchemaManager()->listTableNames(); + } catch (Throwable) { + // We cannot use getDatabase() to get the database name here, because then the driver will try to connect. + // Instead, we read from the raw params. + $shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? ''; + // Create the database using a connection where the dbname was not set. + $this->noDbNameConn->createSchemaManager()->createDatabase($shlinkDatabase); + + return []; + } + } } diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 4b09ed7f..66f46db4 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -12,6 +12,7 @@ use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\Mapping\ClassMetadataFactory; +use Exception; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -69,17 +70,14 @@ class CreateDatabaseCommandTest extends TestCase #[Test] public function successMessageIsPrintedIfDatabaseAlreadyExists(): void { - $shlinkDatabase = 'shlink_database'; - $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); + $this->regularConn->expects($this->never())->method('getParams'); + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $metadataMock = $this->createMock(ClassMetadata::class); $metadataMock->expects($this->once())->method('getTableName')->willReturn('foo_table'); $this->metadataFactory->method('getAllMetadata')->willReturn([$metadataMock]); - $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn( - ['foo', $shlinkDatabase, 'bar'], - ); $this->schemaManager->expects($this->never())->method('createDatabase'); $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']); - $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -90,15 +88,13 @@ class CreateDatabaseCommandTest extends TestCase #[Test] public function databaseIsCreatedIfItDoesNotExist(): void { + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $shlinkDatabase = 'shlink_database'; $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); $this->metadataFactory->method('getAllMetadata')->willReturn([]); - $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(['foo', 'bar']); $this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase); - $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn( - ['foo_table', 'bar_table'], - ); - $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $this->schemaManager->expects($this->once())->method('listTableNames')->willThrowException(new Exception('')); $this->commandTester->execute([]); } @@ -106,14 +102,12 @@ class CreateDatabaseCommandTest extends TestCase #[Test, DataProvider('provideEmptyDatabase')] public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void { - $shlinkDatabase = 'shlink_database'; - $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); + $this->regularConn->expects($this->never())->method('getParams'); + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $metadata = $this->createMock(ClassMetadata::class); $metadata->method('getTableName')->willReturn('shlink_table'); $this->metadataFactory->method('getAllMetadata')->willReturn([$metadata]); - $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn( - ['foo', $shlinkDatabase, 'bar'], - ); $this->schemaManager->expects($this->never())->method('createDatabase'); $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn($tables); $this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [ @@ -122,7 +116,6 @@ class CreateDatabaseCommandTest extends TestCase CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND, '--no-interaction', ]); - $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -141,12 +134,12 @@ class CreateDatabaseCommandTest extends TestCase public function databaseCheckIsSkippedForSqlite(): void { $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(SqlitePlatform::class)); - $this->regularConn->expects($this->never())->method('getParams'); - $this->metadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([]); $this->schemaManager->expects($this->never())->method('listDatabases'); $this->schemaManager->expects($this->never())->method('createDatabase'); - $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']); + $this->schemaManager->expects($this->never())->method('listTableNames'); + + $this->metadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([]); $this->commandTester->execute([]); } From fae3434393c4a7a81e38df5141344a021552841e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 1 Jun 2023 19:28:15 +0200 Subject: [PATCH 11/77] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 204486f1..32c5596d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1790](https://github.com/shlinkio/shlink/issues/1790) Drop support for PHP 8.1. ### Fixed -* *Nothing* +* [#1413](https://github.com/shlinkio/shlink/issues/1413) Fix error when creating initial DB in Postgres in a cluster where a default `postgres` db does not exist or the credentials do not grant permissions to connect. ## [3.6.0] - 2023-05-24 From a11be5b2ff8ee6e2dfabb4221f9f4c5842104518 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 3 Jun 2023 09:08:07 +0200 Subject: [PATCH 12/77] Deprecate support for openswoole --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 6 +++--- Dockerfile | 2 ++ bin/test/run-api-tests.sh | 2 +- build.sh | 2 ++ composer.json | 2 +- config/autoload/logger.global.php | 1 + config/container.php | 1 + docker/README.md | 2 +- docker/docker-entrypoint.sh | 1 + 10 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c5596d..b0cac56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Deprecated -* *Nothing* +* [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. ### Removed * [#1790](https://github.com/shlinkio/shlink/issues/1790) Drop support for PHP 8.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index feb437ad..02325758 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Then you will have to follow these steps: * Run `./indocker bin/cli db:migrate` to get database migrations up to date. * Run `./indocker bin/cli api-key:generate` to get your first API key generated. -Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through openswoole. +Once you finish this, you will have the project exposed in ports `8800` through RoadRunner, `8080` through openswoole and `8000` through nginx+php-fpm. > Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container. @@ -78,7 +78,7 @@ The purposes of every folder are: * `data`: Common runtime-generated git-ignored assets, like logs, caches, etc. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. * `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project. -* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with openswoole. +* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner or openswoole. ## Project tests @@ -94,7 +94,7 @@ In order to ensure stability and no regressions are introduced while developing The project provides some tooling to run them against any of the supported database engines. -* **API tests**: These are E2E tests that spin up an instance of the app with openswoole, and test it from the outside by interacting with the REST API. +* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner or openswoole, and test it from the outside by interacting with the REST API. These are the best tests to catch regressions, and to verify everything behaves as expected. diff --git a/Dockerfile b/Dockerfile index 4637e09e..8c5a9486 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN \ # Install openswoole and sqlsrv driver for x86_64 builds RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ + # Openswoole is deprecated. Remove in v4.0.0 pecl install openswoole-${OPENSWOOLE_VERSION} && \ docker-php-ext-enable openswoole ; \ fi; \ @@ -49,6 +50,7 @@ RUN apk add --no-cache git && \ # FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \ if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ + # Openswoole is deprecated. Remove in v4.0.0 php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \ elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \ php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \ diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 1cbf948a..b22a974e 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -2,7 +2,7 @@ export APP_ENV=test export TEST_ENV=api -export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" +export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" # Openswoole is deprecated. Remove in v4.0.0 export DB_DRIVER="${DB_DRIVER:-"postgres"}" export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" diff --git a/build.sh b/build.sh index 43b240a2..065aee75 100755 --- a/build.sh +++ b/build.sh @@ -10,6 +10,7 @@ fi version=$1 noSwoole=$2 phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') +# Openswoole is deprecated. Remove in v4.0.0 [[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole" distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist" builtContent="./build/${distId}" @@ -38,6 +39,7 @@ if [[ $noSwoole ]]; then # If generating a dist not for openswoole, uninstall mezzio-swoole ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags else + # Deprecated. Remove in Shlink v4.0.0 # If generating a dist for openswoole, uninstall RoadRunner ${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags fi diff --git a/composer.json b/composer.json index 63196aa7..98916e26 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/shlink-common": "^5.5", - "shlinkio/shlink-config": "^2.4", + "shlinkio/shlink-config": "dev-main#245bbdd as 2.5", "shlinkio/shlink-event-dispatcher": "^3.0", "shlinkio/shlink-importer": "^5.1", "shlinkio/shlink-installer": "^8.4", diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 1820c480..01ec40ab 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -52,6 +52,7 @@ return (static function (): array { ], ], + // Deprecated. Remove in Shlink 4.0.0 'mezzio-swoole' => [ 'swoole-http-server' => [ 'logger' => [ diff --git a/config/container.php b/config/container.php index 6813ebd4..e7574fe6 100644 --- a/config/container.php +++ b/config/container.php @@ -13,6 +13,7 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; // Workaround to make this compatible with both openswoole 22 and earlier versions. +// Openswoole support is deprecated. Remove in v4.0.0 if (! function_exists('swoole_set_process_name')) { // phpcs:disable function swoole_set_process_name(string $name): void diff --git a/docker/README.md b/docker/README.md index 629a9ee1..13de359d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -5,7 +5,7 @@ This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime. -It exposes a shlink instance served with [openswoole](https://openswoole.com/), which can be linked to external databases to persist data. +It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev) or [openswoole](https://openswoole.com/), which can be linked to external databases to persist data. ## Usage diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index a2daec3d..12c13996 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -29,6 +29,7 @@ if [ "$SHLINK_RUNTIME" == 'rr' ]; then fi if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then + # Openswoole is deprecated. Remove in Shlink 4.0.0 # When restarting the container, openswoole 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 From 9040937376690a50d030fc31164ac09570569310 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 3 Jun 2023 09:24:43 +0200 Subject: [PATCH 13/77] Stick with PHPUnit 10.1 until API tests coverage is fixed --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 98916e26..2f5ae894 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,7 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.2", "phpunit/php-code-coverage": "^10.0", - "phpunit/phpunit": "^10.1", + "phpunit/phpunit": "~10.1.0", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^3.6", From bd3745118ed82307e168a1e64888e1a2eb1ce129 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 3 Jun 2023 17:56:52 +0200 Subject: [PATCH 14/77] Add logic to prevent roadrunner/openswoole jobs for tasks that will do nothing --- composer.json | 2 +- .../Core/config/event_dispatcher.config.php | 16 ++ .../Helper/EnabledListenerChecker.php | 42 +++++ .../Helper/EnabledListenerCheckerTest.php | 160 ++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php create mode 100644 module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php diff --git a/composer.json b/composer.json index 2f5ae894..99a71e50 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "ramsey/uuid": "^4.7", "shlinkio/shlink-common": "^5.5", "shlinkio/shlink-config": "dev-main#245bbdd as 2.5", - "shlinkio/shlink-event-dispatcher": "^3.0", + "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", "shlinkio/shlink-installer": "^8.4", "shlinkio/shlink-ip-geolocation": "^3.2", diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index e4bf3c0c..a7867240 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -12,7 +12,9 @@ use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; +use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; return [ @@ -54,6 +56,12 @@ return [ EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, + + EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + ], + + 'aliases' => [ + EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, ], 'delegators' => [ @@ -147,6 +155,14 @@ return [ 'Logger_Shlink', EventDispatcherInterface::class, ], + + EventDispatcher\Helper\EnabledListenerChecker::class => [ + Options\RabbitMqOptions::class, + 'config.redis.pub_sub_enabled', + 'config.mercure.public_hub_url', + Options\WebhookOptions::class, + GeoLite2Options::class, + ], ], ]; diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php new file mode 100644 index 00000000..d7e231a2 --- /dev/null +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -0,0 +1,42 @@ + $this->rabbitMqOptions->enabled, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled, + EventDispatcher\Mercure\NotifyVisitToMercure::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->publicMercureHubUrl !== null, + EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(), + EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(), + default => false, // Any unknown async listener should not be enabled by default + }; + } +} diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php new file mode 100644 index 00000000..a98c1e67 --- /dev/null +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -0,0 +1,160 @@ +checker()->shouldRegisterListener('', $listener, false)); + } + + public static function provideListeners(): iterable + { + return [ + [NotifyVisitToRabbitMq::class], + [NotifyNewShortUrlToRabbitMq::class], + [NotifyVisitToRedis::class], + [NotifyNewShortUrlToRedis::class], + [NotifyVisitToMercure::class], + [NotifyNewShortUrlToMercure::class], + [NotifyVisitToWebHooks::class], + [UpdateGeoLiteDb::class], + ]; + } + + /** + * @param array $expectedResult + */ + #[Test, DataProvider('provideConfiguredCheckers')] + public function appropriateListenersAreEnabledBasedOnConfig( + EnabledListenerChecker $checker, + array $expectedResult, + ): void { + foreach ($expectedResult as $listener => $shouldBeRegistered) { + self::assertEquals($shouldBeRegistered, $checker->shouldRegisterListener('', $listener, true)); + } + } + + public static function provideConfiguredCheckers(): iterable + { + yield 'RabbitMQ' => [self::checker(rabbitMqEnabled: true), [ + NotifyVisitToRabbitMq::class => true, + NotifyNewShortUrlToRabbitMq::class => true, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'Redis Pub/Sub' => [self::checker(redisPubSubEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => true, + NotifyNewShortUrlToRedis::class => true, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'Mercure' => [self::checker(mercureEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => true, + NotifyNewShortUrlToMercure::class => true, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'Webhooks' => [self::checker(webhooksEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => true, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'GeoLite' => [self::checker(geoLiteEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => true, + 'unknown' => false, + ]]; + yield 'All disabled' => [self::checker(), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'All enabled' => [self::checker( + rabbitMqEnabled: true, + redisPubSubEnabled: true, + mercureEnabled: true, + webhooksEnabled: true, + geoLiteEnabled: true, + ), [ + NotifyVisitToRabbitMq::class => true, + NotifyNewShortUrlToRabbitMq::class => true, + NotifyVisitToRedis::class => true, + NotifyNewShortUrlToRedis::class => true, + NotifyVisitToMercure::class => true, + NotifyNewShortUrlToMercure::class => true, + NotifyVisitToWebHooks::class => true, + UpdateGeoLiteDb::class => true, + 'unknown' => false, + ]]; + } + + private static function checker( + bool $rabbitMqEnabled = false, + bool $redisPubSubEnabled = false, + bool $mercureEnabled = false, + bool $webhooksEnabled = false, + bool $geoLiteEnabled = false, + ): EnabledListenerChecker { + return new EnabledListenerChecker( + new RabbitMqOptions(enabled: $rabbitMqEnabled), + $redisPubSubEnabled, + $mercureEnabled ? 'the-url' : null, + new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), + new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), + ); + } +} From eff308cd433e38ac84059276f4d366c5da45e59b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 3 Jun 2023 17:58:26 +0200 Subject: [PATCH 15/77] Update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cac56b..084f4435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this When trying to delete orphan visits the result will also be `0` and no visits will actually get deleted. ### Changed -* *Nothing* +* [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled. + + For example, if you did not enable RabbitMQ real-time updates, instead of triggering a job that ends immediately, the job will not even be enqueued. ### Deprecated * [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. From 8e3508f28d4d0f4e19f578f30abd2f06243f1e9a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 6 Jun 2023 20:25:14 +0200 Subject: [PATCH 16/77] Use MercureOptions instead of raw config, where possible --- composer.json | 2 +- module/Core/config/event_dispatcher.config.php | 3 ++- .../src/EventDispatcher/Helper/EnabledListenerChecker.php | 5 +++-- .../EventDispatcher/Helper/EnabledListenerCheckerTest.php | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 99a71e50..6fab70c4 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "^5.5", + "shlinkio/shlink-common": "dev-main#b38c1ad as 5.6", "shlinkio/shlink-config": "dev-main#245bbdd as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index a7867240..ac8626e8 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -9,6 +9,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; +use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; @@ -159,7 +160,7 @@ return [ EventDispatcher\Helper\EnabledListenerChecker::class => [ Options\RabbitMqOptions::class, 'config.redis.pub_sub_enabled', - 'config.mercure.public_hub_url', + MercureOptions::class, Options\WebhookOptions::class, GeoLite2Options::class, ], diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php index d7e231a2..97c0ca5d 100644 --- a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher\Helper; +use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; use Shlinkio\Shlink\Core\Options\WebhookOptions; @@ -15,7 +16,7 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface public function __construct( private readonly RabbitMqOptions $rabbitMqOptions, private readonly bool $redisPubSubEnabled, - private readonly ?string $publicMercureHubUrl, + private readonly MercureOptions $mercureOptions, private readonly WebhookOptions $webhookOptions, private readonly GeoLite2Options $geoLiteOptions, ) { @@ -33,7 +34,7 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled, EventDispatcher\Mercure\NotifyVisitToMercure::class, - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->publicMercureHubUrl !== null, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(), EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(), EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(), default => false, // Any unknown async listener should not be enabled by default diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index a98c1e67..de5017bd 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\Helper; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; @@ -152,7 +153,7 @@ class EnabledListenerCheckerTest extends TestCase return new EnabledListenerChecker( new RabbitMqOptions(enabled: $rabbitMqEnabled), $redisPubSubEnabled, - $mercureEnabled ? 'the-url' : null, + new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null), new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), ); From 22d039c550540d3d6fdda76be972c49943fbb6f8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 11 Jun 2023 19:37:34 +0200 Subject: [PATCH 17/77] Update to latest test utils lib --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 6fab70c4..ffb589b0 100644 --- a/composer.json +++ b/composer.json @@ -75,7 +75,7 @@ "phpunit/phpunit": "~10.1.0", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.6", + "shlinkio/shlink-test-utils": "^3.7", "symfony/var-dumper": "^6.2", "veewee/composer-run-parallel": "^1.2" }, @@ -137,8 +137,8 @@ "infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5", - "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5", - "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5", + "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=70 --configuration=infection-api.json5", + "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=85 --configuration=infection-cli.json5", "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli", "infect:test": [ "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", From 61686ed6eabcd34763b92e3b8808bf28df08ff71 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 14 Jun 2023 18:27:03 +0200 Subject: [PATCH 18/77] Fix JamesIves/github-pages-deploy-action version --- .github/workflows/publish-swagger-spec.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 225a9836..094e8461 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -26,7 +26,7 @@ jobs: - run: mkdir ${{ steps.determine_version.outputs.version }} - run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json - name: Publish spec - uses: JamesIves/github-pages-deploy-action@4.4.1 + uses: JamesIves/github-pages-deploy-action@v4 with: token: ${{ secrets.OAS_PUBLISH_TOKEN }} repository-name: 'shlinkio/shlink-open-api-specs' From cdf5082cff9128ba428aae1659a8070eaa37868c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jun 2023 18:53:42 +0200 Subject: [PATCH 19/77] Fix incorrect timeout in init commands --- CHANGELOG.md | 2 +- composer.json | 2 +- module/CLI/src/Util/ProcessRunner.php | 4 ++-- module/Core/src/Model/DeviceType.php | 2 +- module/Rest/src/Action/Tag/ListTagsAction.php | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b37d090c..730ee968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1790](https://github.com/shlinkio/shlink/issues/1790) Drop support for PHP 8.1. ### Fixed -* *Nothing* +* [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up. ## [3.6.3] - 2023-06-14 diff --git a/composer.json b/composer.json index 2bde0785..c3eafb2e 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "shlinkio/shlink-config": "dev-main#245bbdd as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", - "shlinkio/shlink-installer": "^8.4.1", + "shlinkio/shlink-installer": "^8.4.2", "shlinkio/shlink-ip-geolocation": "^3.2", "shlinkio/shlink-json": "^1.0", "spiral/roadrunner": "^2023.1", diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 1a5471e5..5a568dbe 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -23,8 +23,8 @@ class ProcessRunner implements ProcessRunnerInterface public function __construct(private ProcessHelper $helper, ?callable $createProcess = null) { $this->createProcess = $createProcess !== null - ? Closure::fromCallable($createProcess) - : static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL); + ? $createProcess(...) + : static fn (array $cmd) => new Process($cmd, timeout: LockedCommandConfig::DEFAULT_TTL); } public function run(OutputInterface $output, array $cmd): void diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php index df4a1838..e394716a 100644 --- a/module/Core/src/Model/DeviceType.php +++ b/module/Core/src/Model/DeviceType.php @@ -12,7 +12,7 @@ enum DeviceType: string public static function matchFromUserAgent(string $userAgent): ?self { - $detect = new MobileDetect(null, $userAgent); // @phpstan-ignore-line + $detect = new MobileDetect(userAgent: $userAgent); // @phpstan-ignore-line return match (true) { // $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index d52436d2..34f44475 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -40,7 +40,7 @@ class ListTagsAction extends AbstractRestAction // This part is deprecated. To get tags with stats, the /tags/stats endpoint should be used instead $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); - $rawTags = $this->serializePaginator($tagsInfo, null, 'stats'); + $rawTags = $this->serializePaginator($tagsInfo, dataProp: 'stats'); $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag); return new JsonResponse(['tags' => $rawTags]); From dc4aab2cab97b9cbbdbe7cc2e3a24761ba774679 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Jun 2023 10:36:45 +0200 Subject: [PATCH 20/77] Replace traits with external data providers in API tests --- composer.json | 4 ++++ .../test-api/Action/DeleteShortUrlTest.php | 14 +++++++---- .../Rest/test-api/Action/EditShortUrlTest.php | 10 ++++---- .../test-api/Action/ResolveShortUrlTest.php | 14 +++++++---- .../test-api/Action/ShortUrlVisitsTest.php | 13 +++++------ ...persTrait.php => ApiTestDataProviders.php} | 23 ++++--------------- module/Rest/test-api/Utils/UrlBuilder.php | 23 +++++++++++++++++++ 7 files changed, 60 insertions(+), 41 deletions(-) rename module/Rest/test-api/Utils/{NotFoundUrlHelpersTrait.php => ApiTestDataProviders.php} (62%) create mode 100644 module/Rest/test-api/Utils/UrlBuilder.php diff --git a/composer.json b/composer.json index c3eafb2e..67aee336 100644 --- a/composer.json +++ b/composer.json @@ -148,6 +148,10 @@ "@test:unit:ci", "@infect:ci:unit" ], + "infect:test:db": [ + "@test:db:sqlite:ci", + "@infect:ci:db" + ], "infect:test:api": [ "@test:api:ci", "@infect:ci:api" diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index e15910ac..7bd3dfea 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -5,24 +5,28 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class DeleteShortUrlTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function notFoundErrorIsReturnWhenDeletingInvalidUrl( string $shortCode, ?string $domain, string $expectedDetail, string $apiKey, ): void { - $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); + $resp = $this->callApiWithKey( + self::METHOD_DELETE, + UrlBuilder::buildShortUrlPath($shortCode, $domain), + apiKey: $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 22833970..befb5131 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -9,16 +9,16 @@ use GuzzleHttp\Psr7\Query; use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Uri; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class EditShortUrlTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - #[Test, DataProvider('provideMeta')] public function metadataCanBeReset(array $meta): void { @@ -99,14 +99,14 @@ class EditShortUrlTest extends ApiTestCase yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'INVALID_URL']; } - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, string $expectedDetail, string $apiKey, ): void { - $url = $this->buildShortUrlPath($shortCode, $domain); + $url = UrlBuilder::buildShortUrlPath($shortCode, $domain); $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []], $apiKey); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index 08bc6cb0..b13de872 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -7,16 +7,16 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class ResolveShortUrlTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - #[Test, DataProvider('provideDisabledMeta')] public function shortUrlIsProperlyResolvedEvenWhenNotEnabled(array $disabledMeta): void { @@ -42,14 +42,18 @@ class ResolveShortUrlTest extends ApiTestCase yield 'maxVisits reached' => [['maxVisits' => 1]]; } - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToResolveInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, string $expectedDetail, string $apiKey, ): void { - $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); + $resp = $this->callApiWithKey( + self::METHOD_GET, + UrlBuilder::buildShortUrlPath($shortCode, $domain), + apiKey: $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 70659ed8..6a7e6a7e 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -7,18 +7,18 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\Psr7\Query; use Laminas\Diactoros\Uri; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class ShortUrlVisitsTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, @@ -27,9 +27,8 @@ class ShortUrlVisitsTest extends ApiTestCase ): void { $resp = $this->callApiWithKey( self::METHOD_GET, - $this->buildShortUrlPath($shortCode, $domain, '/visits'), - [], - $apiKey, + UrlBuilder::buildShortUrlPath($shortCode, $domain, '/visits'), + apiKey: $apiKey, ); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php b/module/Rest/test-api/Utils/ApiTestDataProviders.php similarity index 62% rename from module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php rename to module/Rest/test-api/Utils/ApiTestDataProviders.php index 9645f707..0189535b 100644 --- a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php +++ b/module/Rest/test-api/Utils/ApiTestDataProviders.php @@ -4,14 +4,9 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Utils; -use GuzzleHttp\Psr7\Query; -use Laminas\Diactoros\Uri; - -use function sprintf; - -trait NotFoundUrlHelpersTrait +class ApiTestDataProviders { - public static function provideInvalidUrls(): iterable + public static function invalidUrlsProvider(): iterable { yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"', 'valid_api_key']; yield 'invalid shortcode without domain' => [ @@ -20,7 +15,7 @@ trait NotFoundUrlHelpersTrait 'No URL found with short code "abc123" for domain "example.com"', 'valid_api_key', ]; - yield 'invalid shortcode + domain' => [ + yield 'invalid shortcode and custom domain' => [ 'custom-with-domain', 'example.com', 'No URL found with short code "custom-with-domain" for domain "example.com"', @@ -32,21 +27,11 @@ trait NotFoundUrlHelpersTrait 'No URL found with short code "ghi789"', 'author_api_key', ]; - yield 'valid shortcode + domain with invalid API key' => [ + yield 'valid shortcode and custom domain with invalid API key' => [ 'custom-with-domain', 'some-domain.com', 'No URL found with short code "custom-with-domain" for domain "some-domain.com"', 'domain_api_key', ]; } - - public function buildShortUrlPath(string $shortCode, ?string $domain, string $suffix = ''): string - { - $url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix)); - if ($domain !== null) { - $url = $url->withQuery(Query::build(['domain' => $domain])); - } - - return (string) $url; - } } diff --git a/module/Rest/test-api/Utils/UrlBuilder.php b/module/Rest/test-api/Utils/UrlBuilder.php new file mode 100644 index 00000000..6de96a81 --- /dev/null +++ b/module/Rest/test-api/Utils/UrlBuilder.php @@ -0,0 +1,23 @@ +withQuery(Query::build(['domain' => $domain])); + } + + return $url->__toString(); + } +} From bdf2bbd0f1d826f3fd2137bb697da1143f5d96f3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Jun 2023 10:41:24 +0200 Subject: [PATCH 21/77] Replace traits with external data providers in Core unit tests --- .../test/ShortUrl/ShortUrlListServiceTest.php | 8 +++----- .../Core/test/ShortUrl/ShortUrlResolverTest.php | 9 ++++----- .../Core/test/ShortUrl/ShortUrlServiceTest.php | 3 --- module/Core/test/Tag/TagServiceTest.php | 11 +++++------ module/Core/test/Util/ApiKeyHelpersTrait.php | 16 ---------------- module/Core/test/Visit/VisitsStatsHelperTest.php | 13 ++++++------- 6 files changed, 18 insertions(+), 42 deletions(-) delete mode 100644 module/Core/test/Util/ApiKeyHelpersTrait.php diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index 37a3eb36..a469ed60 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,14 +14,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListService; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; use function count; class ShortUrlListServiceTest extends TestCase { - use ApiKeyHelpersTrait; - private ShortUrlListService $service; private MockObject & ShortUrlListRepositoryInterface $repo; @@ -31,7 +29,7 @@ class ShortUrlListServiceTest extends TestCase $this->service = new ShortUrlListService($this->repo, new UrlShortenerOptions()); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index f2b89586..86aa56e7 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,15 +23,13 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; use function Functional\map; use function range; class ShortUrlResolverTest extends TestCase { - use ApiKeyHelpersTrait; - private ShortUrlResolver $urlResolver; private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlRepositoryInterface $repo; @@ -42,7 +41,7 @@ class ShortUrlResolverTest extends TestCase $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void { $shortUrl = ShortUrl::withLongUrl('https://expected_url'); @@ -59,7 +58,7 @@ class ShortUrlResolverTest extends TestCase self::assertSame($shortUrl, $result); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void { $shortCode = 'abc123'; diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 409c937f..67b10720 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -21,15 +21,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function array_fill_keys; use function Shlinkio\Shlink\Core\enumValues; class ShortUrlServiceTest extends TestCase { - use ApiKeyHelpersTrait; - private ShortUrlService $service; private MockObject & ShortUrlResolverInterface $urlResolver; private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 5e1b2665..f22a35f2 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Tag; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,12 +23,10 @@ use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; class TagServiceTest extends TestCase { - use ApiKeyHelpersTrait; - private TagService $service; private MockObject & EntityManagerInterface $em; private MockObject & TagRepository $repo; @@ -101,7 +100,7 @@ class TagServiceTest extends TestCase ]; } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void { $this->repo->expects($this->once())->method('deleteByName')->with(['foo', 'bar'])->willReturn(4); @@ -122,7 +121,7 @@ class TagServiceTest extends TestCase ); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function renameInvalidTagThrowsException(?ApiKey $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(null); @@ -152,7 +151,7 @@ class TagServiceTest extends TestCase yield 'different names names' => ['foo', 'bar', 0]; } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(new Tag('foo')); diff --git a/module/Core/test/Util/ApiKeyHelpersTrait.php b/module/Core/test/Util/ApiKeyHelpersTrait.php deleted file mode 100644 index fc6af8af..00000000 --- a/module/Core/test/Util/ApiKeyHelpersTrait.php +++ /dev/null @@ -1,16 +0,0 @@ - [null]; - yield 'admin API key' => [ApiKey::create()]; - } -} diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 3fc024df..d43efc24 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Laminas\Stdlib\ArrayUtils; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,7 +31,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; use function count; use function Functional\map; @@ -38,8 +39,6 @@ use function range; class VisitsStatsHelperTest extends TestCase { - use ApiKeyHelpersTrait; - private VisitsStatsHelper $helper; private MockObject & EntityManagerInterface $em; @@ -81,7 +80,7 @@ class VisitsStatsHelperTest extends TestCase ]; } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void { $shortCode = '123ABC'; @@ -141,7 +140,7 @@ class VisitsStatsHelperTest extends TestCase $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void { $tag = 'foo'; @@ -179,7 +178,7 @@ class VisitsStatsHelperTest extends TestCase $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void { $domain = 'foo.com'; @@ -207,7 +206,7 @@ class VisitsStatsHelperTest extends TestCase self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void { $repo = $this->createMock(DomainRepository::class); From 965325aa7c2d02e9e78773889af6cf284c623636 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Jun 2023 10:51:59 +0200 Subject: [PATCH 22/77] Replace traits with static classes in CLI unit tests --- .../Command/Api/DisableKeyCommandTest.php | 6 ++--- .../Command/Api/GenerateKeyCommandTest.php | 6 ++--- .../test/Command/Api/ListKeysCommandTest.php | 6 ++--- .../Command/Db/CreateDatabaseCommandTest.php | 6 ++--- .../Command/Db/MigrateDatabaseCommandTest.php | 6 ++--- .../Domain/DomainRedirectsCommandTest.php | 6 ++--- .../Domain/GetDomainVisitsCommandTest.php | 6 ++--- .../Command/Domain/ListDomainsCommandTest.php | 6 ++--- .../ShortUrl/CreateShortUrlCommandTest.php | 6 ++--- .../ShortUrl/DeleteShortUrlCommandTest.php | 6 ++--- .../DeleteShortUrlVisitsCommandTest.php | 6 ++--- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 6 ++--- .../ShortUrl/ListShortUrlsCommandTest.php | 6 ++--- .../ShortUrl/ResolveUrlCommandTest.php | 6 ++--- .../Command/Tag/DeleteTagsCommandTest.php | 6 ++--- .../Command/Tag/GetTagVisitsCommandTest.php | 6 ++--- .../test/Command/Tag/ListTagsCommandTest.php | 6 ++--- .../test/Command/Tag/RenameTagCommandTest.php | 6 ++--- .../Visit/DeleteOrphanVisitsCommandTest.php | 6 ++--- .../Visit/DownloadGeoLiteDbCommandTest.php | 6 ++--- .../Visit/GetNonOrphanVisitsCommandTest.php | 6 ++--- .../Visit/GetOrphanVisitsCommandTest.php | 6 ++--- .../Command/Visit/LocateVisitsCommandTest.php | 8 +++---- .../test/Factory/ApplicationFactoryTest.php | 8 +++---- .../CliTestUtils.php} | 23 +++++++++++++++---- module/Core/test/Util/ApiKeyDataProviders.php | 16 +++++++++++++ 26 files changed, 84 insertions(+), 103 deletions(-) rename module/CLI/test/{CliTestUtilsTrait.php => Util/CliTestUtils.php} (57%) create mode 100644 module/Core/test/Util/ApiKeyDataProviders.php diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 8a1c64e8..a12cb46f 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -10,20 +10,18 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class DisableKeyCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ApiKeyServiceInterface $apiKeyService; protected function setUp(): void { $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService)); + $this->commandTester = CliTestUtils::testerForCommand(new DisableKeyCommand($this->apiKeyService)); } #[Test] diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index b5dbe513..5935242d 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -12,14 +12,12 @@ use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Tester\CommandTester; class GenerateKeyCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ApiKeyServiceInterface $apiKeyService; @@ -30,7 +28,7 @@ class GenerateKeyCommandTest extends TestCase $roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]); $command = new GenerateKeyCommand($this->apiKeyService, $roleResolver); - $this->commandTester = $this->testerForCommand($command); + $this->commandTester = CliTestUtils::testerForCommand($command); } #[Test] diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index e4cdb438..0f3e8cb6 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -15,20 +15,18 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class ListKeysCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ApiKeyServiceInterface $apiKeyService; protected function setUp(): void { $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); - $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService)); + $this->commandTester = CliTestUtils::testerForCommand(new ListKeysCommand($this->apiKeyService)); } #[Test, DataProvider('provideKeysAndOutputs')] diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 420ea91d..cece20db 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -18,7 +18,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; @@ -27,8 +27,6 @@ use Symfony\Component\Process\PhpExecutableFinder; class CreateDatabaseCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ProcessRunnerInterface $processHelper; private MockObject & Connection $regularConn; @@ -63,7 +61,7 @@ class CreateDatabaseCommandTest extends TestCase $noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager); $command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn); - $this->commandTester = $this->testerForCommand($command); + $this->commandTester = CliTestUtils::testerForCommand($command); } #[Test] diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 7bdbfca0..ac4283d7 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; @@ -18,8 +18,6 @@ use Symfony\Component\Process\PhpExecutableFinder; class MigrateDatabaseCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ProcessRunnerInterface $processHelper; @@ -36,7 +34,7 @@ class MigrateDatabaseCommandTest extends TestCase $this->processHelper = $this->createMock(ProcessRunnerInterface::class); $command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder); - $this->commandTester = $this->testerForCommand($command); + $this->commandTester = CliTestUtils::testerForCommand($command); } #[Test] diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 48125d91..0bc77aca 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -14,22 +14,20 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function substr_count; class DomainRedirectsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & DomainServiceInterface $domainService; protected function setUp(): void { $this->domainService = $this->createMock(DomainServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService)); + $this->commandTester = CliTestUtils::testerForCommand(new DomainRedirectsCommand($this->domainService)); } #[Test, DataProvider('provideDomains')] diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index bdee0ed4..7f4bd076 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -17,13 +17,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class GetDomainVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; private MockObject & ShortUrlStringifierInterface $stringifier; @@ -33,7 +31,7 @@ class GetDomainVisitsCommandTest extends TestCase $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); - $this->commandTester = $this->testerForCommand( + $this->commandTester = CliTestUtils::testerForCommand( new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier), ); } diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 05cc95eb..cfa09e18 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -15,20 +15,18 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class ListDomainsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & DomainServiceInterface $domainService; protected function setUp(): void { $this->domainService = $this->createMock(DomainServiceInterface::class); - $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService)); + $this->commandTester = CliTestUtils::testerForCommand(new ListDomainsCommand($this->domainService)); } #[Test, DataProvider('provideInputsAndOutputs')] diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 46063485..de0fe26b 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -20,14 +20,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult; use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; class CreateShortUrlCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & UrlShortenerInterface $urlShortener; private MockObject & ShortUrlStringifierInterface $stringifier; @@ -45,7 +43,7 @@ class CreateShortUrlCommandTest extends TestCase defaultShortCodesLength: 5, ), ); - $this->commandTester = $this->testerForCommand($command); + $this->commandTester = CliTestUtils::testerForCommand($command); } #[Test] diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 06081983..0402dc8c 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; @@ -21,15 +21,13 @@ use const PHP_EOL; class DeleteShortUrlCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & DeleteShortUrlServiceInterface $service; protected function setUp(): void { $this->service = $this->createMock(DeleteShortUrlServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service)); + $this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlCommand($this->service)); } #[Test] diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php index 88c3657a..2a281a8a 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php @@ -13,20 +13,18 @@ use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\BulkDeleteResult; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class DeleteShortUrlVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ShortUrlVisitsDeleterInterface $deleter; protected function setUp(): void { $this->deleter = $this->createMock(ShortUrlVisitsDeleterInterface::class); - $this->commandTester = $this->testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter)); + $this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter)); } #[Test, DataProvider('provideCancellingInputs')] diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 13d36f4b..f93ab5ec 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -20,7 +20,7 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function Shlinkio\Shlink\Common\buildDateRange; @@ -28,8 +28,6 @@ use function sprintf; class GetShortUrlVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; @@ -37,7 +35,7 @@ class GetShortUrlVisitsCommandTest extends TestCase { $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); $command = new GetShortUrlVisitsCommand($this->visitsHelper); - $this->commandTester = $this->testerForCommand($command); + $this->commandTester = CliTestUtils::testerForCommand($command); } #[Test] diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index d81172ed..2c6a0979 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -21,7 +21,7 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function count; @@ -29,8 +29,6 @@ use function explode; class ListShortUrlsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ShortUrlListServiceInterface $shortUrlService; @@ -40,7 +38,7 @@ class ListShortUrlsCommandTest extends TestCase $command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); - $this->commandTester = $this->testerForCommand($command); + $this->commandTester = CliTestUtils::testerForCommand($command); } #[Test] diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 21452ed6..9c9bbb93 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; @@ -21,15 +21,13 @@ use const PHP_EOL; class ResolveUrlCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & ShortUrlResolverInterface $urlResolver; protected function setUp(): void { $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); - $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver)); + $this->commandTester = CliTestUtils::testerForCommand(new ResolveUrlCommand($this->urlResolver)); } #[Test] diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index d818ba54..7bbd5966 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -9,20 +9,18 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class DeleteTagsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & TagServiceInterface $tagService; protected function setUp(): void { $this->tagService = $this->createMock(TagServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService)); + $this->commandTester = CliTestUtils::testerForCommand(new DeleteTagsCommand($this->tagService)); } #[Test] diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index ef34952d..a2dc059f 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -17,13 +17,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class GetTagVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; private MockObject & ShortUrlStringifierInterface $stringifier; @@ -33,7 +31,7 @@ class GetTagVisitsCommandTest extends TestCase $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); - $this->commandTester = $this->testerForCommand( + $this->commandTester = CliTestUtils::testerForCommand( new GetTagVisitsCommand($this->visitsHelper, $this->stringifier), ); } diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index e1020667..1cfb3d3b 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -12,20 +12,18 @@ use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class ListTagsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & TagServiceInterface $tagService; protected function setUp(): void { $this->tagService = $this->createMock(TagServiceInterface::class); - $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService)); + $this->commandTester = CliTestUtils::testerForCommand(new ListTagsCommand($this->tagService)); } #[Test] diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 7dfe474f..296926b8 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -12,20 +12,18 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class RenameTagCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & TagServiceInterface $tagService; protected function setUp(): void { $this->tagService = $this->createMock(TagServiceInterface::class); - $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService)); + $this->commandTester = CliTestUtils::testerForCommand(new RenameTagCommand($this->tagService)); } #[Test] diff --git a/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php index c18fe7f4..cd39c63a 100644 --- a/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php @@ -11,20 +11,18 @@ use Shlinkio\Shlink\CLI\Command\Visit\DeleteOrphanVisitsCommand; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Model\BulkDeleteResult; use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class DeleteOrphanVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitsDeleterInterface $deleter; protected function setUp(): void { $this->deleter = $this->createMock(VisitsDeleterInterface::class); - $this->commandTester = $this->testerForCommand(new DeleteOrphanVisitsCommand($this->deleter)); + $this->commandTester = CliTestUtils::testerForCommand(new DeleteOrphanVisitsCommand($this->deleter)); } #[Test] diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 7e904caa..78e14fa9 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -13,22 +13,20 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; use Shlinkio\Shlink\CLI\Util\ExitCode; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; class DownloadGeoLiteDbCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & GeolocationDbUpdaterInterface $dbUpdater; protected function setUp(): void { $this->dbUpdater = $this->createMock(GeolocationDbUpdaterInterface::class); - $this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater)); + $this->commandTester = CliTestUtils::testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater)); } #[Test, DataProvider('provideFailureParams')] diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index dabfdb06..439b33bd 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -17,13 +17,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class GetNonOrphanVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; private MockObject & ShortUrlStringifierInterface $stringifier; @@ -33,7 +31,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); - $this->commandTester = $this->testerForCommand( + $this->commandTester = CliTestUtils::testerForCommand( new GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier), ); } diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 226cb927..b90e6af6 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -15,20 +15,18 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; class GetOrphanVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); - $this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper)); + $this->commandTester = CliTestUtils::testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper)); } #[Test] diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 6ff8c242..031e8e45 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -21,7 +21,7 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\OutputInterface; @@ -34,8 +34,6 @@ use const PHP_EOL; class LocateVisitsCommandTest extends TestCase { - use CliTestUtilsTrait; - private CommandTester $commandTester; private MockObject & VisitLocatorInterface $visitService; private MockObject & VisitToLocationHelperInterface $visitToLocation; @@ -53,8 +51,8 @@ class LocateVisitsCommandTest extends TestCase $command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker); - $this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME); - $this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand); + $this->downloadDbCommand = CliTestUtils::createCommandMock(DownloadGeoLiteDbCommand::NAME); + $this->commandTester = CliTestUtils::testerForCommand($command, $this->downloadDbCommand); } #[Test, DataProvider('provideArgs')] diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index 3d75c647..83b0fc66 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -9,12 +9,10 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Factory\ApplicationFactory; use Shlinkio\Shlink\Core\Options\AppOptions; -use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; class ApplicationFactoryTest extends TestCase { - use CliTestUtilsTrait; - private ApplicationFactory $factory; protected function setUp(): void @@ -32,8 +30,8 @@ class ApplicationFactoryTest extends TestCase 'baz' => 'baz', ], ]); - $sm->setService('foo', $this->createCommandMock('foo')); - $sm->setService('bar', $this->createCommandMock('bar')); + $sm->setService('foo', CliTestUtils::createCommandMock('foo')); + $sm->setService('bar', CliTestUtils::createCommandMock('bar')); $instance = ($this->factory)($sm); diff --git a/module/CLI/test/CliTestUtilsTrait.php b/module/CLI/test/Util/CliTestUtils.php similarity index 57% rename from module/CLI/test/CliTestUtilsTrait.php rename to module/CLI/test/Util/CliTestUtils.php index 761567ae..e5fd7477 100644 --- a/module/CLI/test/CliTestUtilsTrait.php +++ b/module/CLI/test/Util/CliTestUtils.php @@ -2,20 +2,33 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\CLI; +namespace ShlinkioTest\Shlink\CLI\Util; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\Generator; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Tester\CommandTester; -trait CliTestUtilsTrait +class CliTestUtils { - private function createCommandMock(string $name): MockObject & Command + public static function createCommandMock(string $name): MockObject & Command { - $command = $this->createMock(Command::class); + static $generator = null; + + if ($generator === null) { + $generator = new Generator(); + } + + $command = $generator->getMock( + Command::class, + callOriginalConstructor: false, + callOriginalClone: false, + cloneArguments: false, + allowMockingUnknownTypes: false, + ); $command->method('getName')->willReturn($name); $command->method('isEnabled')->willReturn(true); $command->method('getAliases')->willReturn([]); @@ -25,7 +38,7 @@ trait CliTestUtilsTrait return $command; } - private function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester + public static function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester { $app = new Application(); $app->add($mainCommand); diff --git a/module/Core/test/Util/ApiKeyDataProviders.php b/module/Core/test/Util/ApiKeyDataProviders.php new file mode 100644 index 00000000..72956e5b --- /dev/null +++ b/module/Core/test/Util/ApiKeyDataProviders.php @@ -0,0 +1,16 @@ + [null]; + yield 'admin API key' => [ApiKey::create()]; + } +} From 33eedd22706f99c59d29e5dcf1f2b0a30fa46579 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Jun 2023 18:59:15 +0200 Subject: [PATCH 23/77] Update shlink-test-utils to fix coverage ID on API tests --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 67aee336..0bb4ab93 100644 --- a/composer.json +++ b/composer.json @@ -72,10 +72,10 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.2", "phpunit/php-code-coverage": "^10.0", - "phpunit/phpunit": "~10.1.0", + "phpunit/phpunit": "^10.2.0", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.7", + "shlinkio/shlink-test-utils": "^3.7.1", "symfony/var-dumper": "^6.2", "veewee/composer-run-parallel": "^1.2" }, @@ -137,8 +137,8 @@ "infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5", - "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=70 --configuration=infection-api.json5", - "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=85 --configuration=infection-cli.json5", + "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=95 --configuration=infection-api.json5", + "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5", "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli", "infect:test": [ "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", From 453fcc46756d4b037f5bee26bcc31a0d47cdd947 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Jun 2023 08:54:03 +0200 Subject: [PATCH 24/77] Make sure people asking questions opens a discussion instead of an issue --- .github/ISSUE_TEMPLATE/Bug.md | 2 +- .github/ISSUE_TEMPLATE/Feature_Request.md | 2 +- .github/ISSUE_TEMPLATE/Question_Support.md | 26 ---------------------- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ CONTRIBUTING.md | 2 +- build.sh | 2 +- 6 files changed, 9 insertions(+), 30 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/Question_Support.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/Bug.md b/.github/ISSUE_TEMPLATE/Bug.md index 9d8b644c..55e060de 100644 --- a/.github/ISSUE_TEMPLATE/Bug.md +++ b/.github/ISSUE_TEMPLATE/Bug.md @@ -1,6 +1,6 @@ --- name: Bug report -about: Something on shlink is broken or not working as documented? +about: Something on Shlink is broken or not working as documented? labels: bug --- diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.md b/.github/ISSUE_TEMPLATE/Feature_Request.md index dcfc37ad..835f705b 100644 --- a/.github/ISSUE_TEMPLATE/Feature_Request.md +++ b/.github/ISSUE_TEMPLATE/Feature_Request.md @@ -1,6 +1,6 @@ --- name: Feature request -about: Do you find shlink is missing some important feature that would make it more useful? +about: Do you find Shlink is missing some important feature that would make it more useful? labels: feature --- diff --git a/.github/ISSUE_TEMPLATE/Question_Support.md b/.github/ISSUE_TEMPLATE/Question_Support.md deleted file mode 100644 index 28e5e022..00000000 --- a/.github/ISSUE_TEMPLATE/Question_Support.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Question - Support -about: Do you have a problem setting up or using shlink? -labels: question ---- - - - -#### How Shlink is set up - -* Shlink Version: x.y.z -* PHP Version: x.y.z -* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image -* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z) - -#### Summary - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..53fca8ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Question - Support + about: Do you need help setting up or using Shlink? + url: https://github.com/shlinkio/shlink/discussions/new?category=help-wanted diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02325758..79b4c8b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ shlink The purposes of every folder are: -* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line. +* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run Shlink from the command line. * `config`: Contains application-wide configurations, which are later merged with the ones provided by every module. * `data`: Common runtime-generated git-ignored assets, like logs, caches, etc. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. diff --git a/build.sh b/build.sh index 065aee75..f91ab408 100755 --- a/build.sh +++ b/build.sh @@ -48,7 +48,7 @@ fi echo 'Deleting dev files...' rm composer.* -# Update shlink version in config +# Update Shlink version in config sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php # Compressing file From 1d7c9fd55329e14aff191cfcde8db67392924833 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Jun 2023 09:16:33 +0200 Subject: [PATCH 25/77] Refactor cli-config file as it's currently used by doctrine migrations only --- config/cli-config.php | 22 ++++++++++++++++++++-- migrations.php | 15 --------------- 2 files changed, 20 insertions(+), 17 deletions(-) delete mode 100644 migrations.php diff --git a/config/cli-config.php b/config/cli-config.php index 52659e4e..1f211557 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -2,11 +2,29 @@ declare(strict_types=1); +use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager; +use Doctrine\Migrations\Configuration\Migration\ConfigurationArray; +use Doctrine\Migrations\DependencyFactory; use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Tools\Console\ConsoleRunner; + +// This file is currently used by docrtrine migrations only return (static function () { /** @var EntityManager $em */ $em = include __DIR__ . '/entity-manager.php'; - return ConsoleRunner::createHelperSet($em); + + $migrationsConfig = [ + 'migrations_paths' => [ + 'ShlinkMigrations' => 'data/migrations', + ], + 'table_storage' => [ + 'table_name' => 'migrations', + ], + 'custom_template' => 'data/migrations_template.txt', + ]; + + return DependencyFactory::fromEntityManager( + new ConfigurationArray($migrationsConfig), + new ExistingEntityManager($em), + ); })(); diff --git a/migrations.php b/migrations.php deleted file mode 100644 index 306c1c08..00000000 --- a/migrations.php +++ /dev/null @@ -1,15 +0,0 @@ - [ - 'ShlinkMigrations' => 'data/migrations', - ], - 'table_storage' => [ - 'table_name' => 'migrations', - ], - 'custom_template' => 'data/migrations_template.txt', - -]; From af5088736192261781b121f0b5a4d0d8c36c5e0e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jul 2023 16:33:52 +0200 Subject: [PATCH 26/77] Fix typo --- config/cli-config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/cli-config.php b/config/cli-config.php index 1f211557..fef6fb9c 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -7,7 +7,7 @@ use Doctrine\Migrations\Configuration\Migration\ConfigurationArray; use Doctrine\Migrations\DependencyFactory; use Doctrine\ORM\EntityManager; -// This file is currently used by docrtrine migrations only +// This file is currently used by doctrine migrations only return (static function () { /** @var EntityManager $em */ From 7cc1722858ca8b8eeb425820e1f56d9d8d665913 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jul 2023 09:58:51 +0200 Subject: [PATCH 27/77] Improve verbosity hint when an error occurs during docker init --- composer.json | 6 +++--- docker/docker-entrypoint.sh | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 0bb4ab93..ba72eed1 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "laminas/laminas-config-aggregator": "^1.13", "laminas/laminas-diactoros": "^2.24", "laminas/laminas-inputfilter": "^2.24", - "laminas/laminas-servicemanager": "^3.20", + "laminas/laminas-servicemanager": "^3.21", "laminas/laminas-stdlib": "^3.16", "league/uri": "^6.8", "lstrojny/functional-php": "^1.17", @@ -46,10 +46,10 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/shlink-common": "dev-main#b38c1ad as 5.6", - "shlinkio/shlink-config": "dev-main#245bbdd as 2.5", + "shlinkio/shlink-config": "dev-main#c0aa01f as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", - "shlinkio/shlink-installer": "^8.4.2", + "shlinkio/shlink-installer": "dev-develop#b393e6b as 8.5", "shlinkio/shlink-ip-geolocation": "^3.2", "shlinkio/shlink-json": "^1.0", "spiral/roadrunner": "^2023.1", diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 8f673442..09bf2ef2 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -3,10 +3,10 @@ set -e cd /etc/shlink -flags="--clear-db-cache" +flags="--no-interaction --clear-db-cache" # Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set -if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" == "true" ]; then +if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" = "true" ]; then flags="${flags} --skip-download-geolite" fi @@ -20,11 +20,11 @@ if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "ro /usr/sbin/crond & fi -if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then +if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then # Openswoole is deprecated. Remove in Shlink 4.0.0 # When restarting the container, openswoole 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 -elif [ "$SHLINK_RUNTIME" == 'rr' ]; then +elif [ "$SHLINK_RUNTIME" = 'rr' ]; then ./bin/rr serve -c config/roadrunner/.rr.yml fi From 8b495064b244ca05f04e79c960a6af7108701376 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jul 2023 09:45:46 +0200 Subject: [PATCH 28/77] Build docker image only on tags --- .github/workflows/publish-docker-image.yml | 10 ---------- CHANGELOG.md | 2 ++ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index 3dda2ead..ee9276fd 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -2,16 +2,6 @@ name: Build and publish docker image on: push: - paths-ignore: - - 'LICENSE' - - '.*' - - '*.md' - - '*.xml' - - '*.yml*' - - '*.json5' - - '*.neon' - branches: - - develop tags: - 'v*' diff --git a/CHANGELOG.md b/CHANGELOG.md index 730ee968..381021fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this For example, if you did not enable RabbitMQ real-time updates, instead of triggering a job that ends immediately, the job will not even be enqueued. +* [#1835](https://github.com/shlinkio/shlink/issues/1835) Docker image is now built only when a release is tagged, and new tags are included, for minor and major versions. + ### Deprecated * [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. From 262d7147513392b2d3046798ab15e6ebfdb6e910 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jul 2023 11:31:13 +0200 Subject: [PATCH 29/77] Add ADR for latest docker image publishing change --- ...t-docker-image-only-for-actual-releases.md | 52 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 53 insertions(+) create mode 100644 docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md diff --git a/docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md b/docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md new file mode 100644 index 00000000..aad742f9 --- /dev/null +++ b/docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md @@ -0,0 +1,52 @@ +# Build `latest` docker image only for actual releases + +* Status: Accepted +* Date: 2023-07-09 + +## Context and problem statement + +Historically, this project has re-tagged the `latest´ docker image every time a PR was merged into default branch. + +The reason was to be able to: + +* Periodically test the docker building and publishing process. +* Provide "partial" images for quick testing of new "un-released" features. + +However, this was considered non-stable, and not recommended to use in production. Instead, a convenient `stable` tag was provided, which was re-tagged for every new non-beta/non-alpha release. + +The approach described above for `latest` has some problems, though: + +* Many people ignore the recommendation of not using it in production. There have even been reports of bugs on things which were, technically speaking, not yet released. +* Since it is not always built for an actual new project version, the project itself cannot inform about anything other than `latest`, which can quickly become a lie if you don't update your local version. + +## Considered options + +* Try to provide a pseudo-version when `latest` is built. Something like `-. +* Change how `latest` is published, and start tagging it only for actual new version releases. +* Same as the above, but exclude alpha/beta versions, deprecating `stable` tag. + +## Decision outcome + +Since testing un-released features has never been needed, it is probably a not-very useful thing to have. + +Periodically testing the build and publish process can also be moved somewhere else, like a testing "hidden" account. + +Also, having `stable` with non-alpha/non-beta releases seems sensible, so the decision is to "Change how `latest` is published, and start tagging it only for actual new version releases". + +## Pros and Cons of the Options + +### Try to provide a pseudo-version when `latest` is built. + +* Good: because we keep publishing process intact, from a user point of view. +* Bad: because it requires adding some non-trivial logic to the image building, which needs to find out what was the latest stable release. + +### Make `latest` hold latest published version, including unstable releases. + +* Good: because it provides a way for users to test bleeding-edge features, with less risk than relying on the very last content from default branch. +* Good: because it allows for `stable` to be used together with `latest`. +* Bad: because partial features cannot be tested without publishing an alpha or beta version. + +### Make `latest` hold latest published version, excluding unstable releases. + +* Bad: because there's no longer a way to test bleeding-edge features, other than installing that specific version. +* Bad: because it drives `stable` useless, which means it needs to be deprecated, documented, and eventually removed. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9d87a0fb..bafb80b5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,6 +2,7 @@ Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome. +* [2023-07-09Build `latest` docker image only for actual releases](2023-07-09-build-latest-docker-image-only-for-actual-releases.md) * [2023-01-06 Support any HTTP method in short URLs](2023-01-06-support-any-http-method-in-short-urls.md) * [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md) * [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md) From 78f75a06df492e5eb3b04135468a5f1098e296c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jul 2023 16:56:07 +0200 Subject: [PATCH 30/77] Updated swagger docs to v3.1, and fixed some 'required' definitions --- composer.json | 2 +- docs/swagger/definitions/DeviceLongUrls.json | 9 +++------ docs/swagger/definitions/DeviceLongUrlsEdit.json | 6 +++--- docs/swagger/swagger.json | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index ba72eed1..3a970ee5 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "symfony/string": "^6.2" }, "require-dev": { - "cebe/php-openapi": "^1.7", + "devizzent/cebe-php-openapi": "dev-I-5_server_definition_fix as 1.1", "devster/ubench": "^2.1", "infection/infection": "^0.27", "openswoole/ide-helper": "~22.0.0", diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json index 1a56d9ef..0e8719db 100644 --- a/docs/swagger/definitions/DeviceLongUrls.json +++ b/docs/swagger/definitions/DeviceLongUrls.json @@ -3,18 +3,15 @@ "properties": { "android": { "description": "The long URL to redirect to when the short URL is visited from a device running Android", - "type": "string", - "nullable": false + "type": ["string"] }, "ios": { "description": "The long URL to redirect to when the short URL is visited from a device running iOS", - "type": "string", - "nullable": false + "type": ["string"] }, "desktop": { "description": "The long URL to redirect to when the short URL is visited from a desktop browser", - "type": "string", - "nullable": false + "type": ["string"] } } } diff --git a/docs/swagger/definitions/DeviceLongUrlsEdit.json b/docs/swagger/definitions/DeviceLongUrlsEdit.json index 78f77e46..f1ff255f 100644 --- a/docs/swagger/definitions/DeviceLongUrlsEdit.json +++ b/docs/swagger/definitions/DeviceLongUrlsEdit.json @@ -5,13 +5,13 @@ }], "properties": { "android": { - "nullable": true + "type": ["null"] }, "ios": { - "nullable": true + "type": ["null"] }, "desktop": { - "nullable": true + "type": ["null"] } } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index b80ae3b2..51655ecf 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.3", + "openapi": "3.1.0", "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", From ad1a846d8e6b4ca39f720aeaa4c29a9bc20726ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jul 2023 17:04:24 +0200 Subject: [PATCH 31/77] Remove references to nullable in OAS --- docs/swagger/definitions/NotFoundRedirects.json | 9 +++------ docs/swagger/definitions/OrphanVisit.json | 3 +-- docs/swagger/definitions/ShortUrl.json | 6 ++---- docs/swagger/definitions/ShortUrlEdition.json | 14 +++++--------- docs/swagger/definitions/ShortUrlMeta.json | 9 +++------ 5 files changed, 14 insertions(+), 27 deletions(-) diff --git a/docs/swagger/definitions/NotFoundRedirects.json b/docs/swagger/definitions/NotFoundRedirects.json index 6887ed0c..d0459f90 100644 --- a/docs/swagger/definitions/NotFoundRedirects.json +++ b/docs/swagger/definitions/NotFoundRedirects.json @@ -2,18 +2,15 @@ "type": "object", "properties": { "baseUrlRedirect": { - "type": "string", - "nullable": true, + "type": ["string", "null"], "description": "URL to redirect to when a user hits the domain's base URL" }, "regular404Redirect": { - "type": "string", - "nullable": true, + "type": ["string", "null"], "description": "URL to redirect to when a user hits a not found URL other than an invalid short URL" }, "invalidShortUrlRedirect": { - "type": "string", - "nullable": true, + "type": ["string", "null"], "description": "URL to redirect to when a user hits an invalid short URL" } } diff --git a/docs/swagger/definitions/OrphanVisit.json b/docs/swagger/definitions/OrphanVisit.json index 04d8386d..a8b4954a 100644 --- a/docs/swagger/definitions/OrphanVisit.json +++ b/docs/swagger/definitions/OrphanVisit.json @@ -6,8 +6,7 @@ }], "properties": { "visitedUrl": { - "type": "string", - "nullable": true, + "type": ["string", "null"], "description": "The originally visited URL that triggered the tracking of this visit" }, "type": { diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 4060e2f2..98fd9c87 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -55,13 +55,11 @@ "$ref": "./ShortUrlMeta.json" }, "domain": { - "type": "string", - "nullable": true, + "type": ["string", "null"], "description": "The domain in which the short URL was created. Null if it belongs to default domain." }, "title": { - "type": "string", - "nullable": true, + "type": ["string", "null"], "description": "A descriptive title of the short URL." }, "crawlable": { diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index ed3c3929..dda213ca 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -10,18 +10,15 @@ }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", - "type": "string", - "nullable": true + "type": ["string", "null"] }, "validUntil": { "description": "The date (in ISO-8601 format) until which this short code will be valid", - "type": "string", - "nullable": true + "type": ["string", "null"] }, "maxVisits": { "description": "The maximum number of allowed visits for this short code", - "type": "number", - "nullable": true + "type": ["number", "null"] }, "validateUrl": { "deprecated": true, @@ -36,9 +33,8 @@ "description": "The list of tags to set to the short URL." }, "title": { - "type": "string", - "description": "A descriptive title of the short URL.", - "nullable": true + "type": ["string", "null"], + "description": "A descriptive title of the short URL." }, "crawlable": { "type": "boolean", diff --git a/docs/swagger/definitions/ShortUrlMeta.json b/docs/swagger/definitions/ShortUrlMeta.json index 370a548b..d687d97f 100644 --- a/docs/swagger/definitions/ShortUrlMeta.json +++ b/docs/swagger/definitions/ShortUrlMeta.json @@ -4,18 +4,15 @@ "properties": { "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", - "type": "string", - "nullable": true + "type": ["string", "null"] }, "validUntil": { "description": "The date (in ISO-8601 format) until which this short code will be valid", - "type": "string", - "nullable": true + "type": ["string", "null"] }, "maxVisits": { "description": "The maximum number of allowed visits for this short code", - "type": "number", - "nullable": true + "type": ["number", "null"] } } } From 27e90c4c261754a1ef60308a235a7d8e904bd21b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jul 2023 17:15:20 +0200 Subject: [PATCH 32/77] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 381021fb..e5a78e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this For example, if you did not enable RabbitMQ real-time updates, instead of triggering a job that ends immediately, the job will not even be enqueued. * [#1835](https://github.com/shlinkio/shlink/issues/1835) Docker image is now built only when a release is tagged, and new tags are included, for minor and major versions. +* [#1055](https://github.com/shlinkio/shlink/issues/1055) Update OAS definition to v3.1. ### Deprecated * [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. From b47bd0fc7ae1c523d31eef6921fddb5965e342e1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 12 Jul 2023 11:33:58 +0200 Subject: [PATCH 33/77] Use stable version of devizzent/cebe-php-openapi --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3a970ee5..d9e0e63f 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "symfony/string": "^6.2" }, "require-dev": { - "devizzent/cebe-php-openapi": "dev-I-5_server_definition_fix as 1.1", + "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", "infection/infection": "^0.27", "openswoole/ide-helper": "~22.0.0", From 2d085ad6f4fd190341f9a90d57f97c36ca6cac2c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jul 2023 10:58:24 +0200 Subject: [PATCH 34/77] Add discussion template for 'Help wanted' --- .github/DISCUSSION_TEMPLATE/help-wanted.yml | 51 +++++++++++++++++++++ composer.json | 4 +- config/cli-config.php | 5 +- 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 .github/DISCUSSION_TEMPLATE/help-wanted.yml diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml new file mode 100644 index 00000000..29956209 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -0,0 +1,51 @@ +title: 'Help wanted' +body: + - type: input + validations: + required: true + attributes: + label: Shlink version + placeholder: x.y.z + - type: input + validations: + required: true + attributes: + label: PHP version + placeholder: x.y.z + - type: dropdown + validations: + required: true + attributes: + label: How do you serve Shlink + options: + - Self-hosted Apache + - Self-hosted nginx + - Self-hosted openswoole + - Self-hosted RoadRunner + - Openswoole Docker image + - RoadRunner Docker image + - Other (explain in summary) + - type: dropdown + validations: + required: true + attributes: + label: Database engine + options: + - MySQL + - MariaDB + - PostgreSQL + - MicrosoftSQL + - SQLite + - type: input + validations: + required: true + attributes: + label: Database version + placeholder: x.y.z + - type: markdown + validations: + required: true + attributes: + label: Summary + value: '' + diff --git a/composer.json b/composer.json index d9e0e63f..9aa0ed98 100644 --- a/composer.json +++ b/composer.json @@ -67,10 +67,10 @@ "devster/ubench": "^2.1", "infection/infection": "^0.27", "openswoole/ide-helper": "~22.0.0", - "phpstan/phpstan": "^1.9", + "phpstan/phpstan": "^1.10", "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-symfony": "^1.2", + "phpstan/phpstan-symfony": "^1.3", "phpunit/php-code-coverage": "^10.0", "phpunit/phpunit": "^10.2.0", "roave/security-advisories": "dev-master", diff --git a/config/cli-config.php b/config/cli-config.php index fef6fb9c..57348824 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -5,14 +5,10 @@ declare(strict_types=1); use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager; use Doctrine\Migrations\Configuration\Migration\ConfigurationArray; use Doctrine\Migrations\DependencyFactory; -use Doctrine\ORM\EntityManager; // This file is currently used by doctrine migrations only return (static function () { - /** @var EntityManager $em */ - $em = include __DIR__ . '/entity-manager.php'; - $migrationsConfig = [ 'migrations_paths' => [ 'ShlinkMigrations' => 'data/migrations', @@ -22,6 +18,7 @@ return (static function () { ], 'custom_template' => 'data/migrations_template.txt', ]; + $em = include __DIR__ . '/entity-manager.php'; return DependencyFactory::fromEntityManager( new ConfigurationArray($migrationsConfig), From 550f3b28ea45e6b7d4c751d4f81b8a7eacdf5387 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jul 2023 11:09:36 +0200 Subject: [PATCH 35/77] Use textarea instead of markdown for main field in help-wanted discussion --- .github/DISCUSSION_TEMPLATE/help-wanted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml index 29956209..1283f43d 100644 --- a/.github/DISCUSSION_TEMPLATE/help-wanted.yml +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -42,7 +42,7 @@ body: attributes: label: Database version placeholder: x.y.z - - type: markdown + - type: textarea validations: required: true attributes: From 4b49f8fb7f1d5c8bdd3b028637d88edff57f2d04 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 25 Jul 2023 08:45:24 +0200 Subject: [PATCH 36/77] Use issue form for bugs --- .github/ISSUE_TEMPLATE/Bug.md | 38 -------------- .github/ISSUE_TEMPLATE/Bug.yml | 64 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/Feature_Request.md | 10 ---- 3 files changed, 64 insertions(+), 48 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/Bug.md create mode 100644 .github/ISSUE_TEMPLATE/Bug.yml diff --git a/.github/ISSUE_TEMPLATE/Bug.md b/.github/ISSUE_TEMPLATE/Bug.md deleted file mode 100644 index 55e060de..00000000 --- a/.github/ISSUE_TEMPLATE/Bug.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Something on Shlink is broken or not working as documented? -labels: bug ---- - - - -#### How Shlink is set up - -* Shlink Version: x.y.z -* PHP Version: x.y.z -* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image -* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z) - -#### Summary - - - -#### Current behavior - - - -#### Expected behavior - - - -#### How to reproduce - - diff --git a/.github/ISSUE_TEMPLATE/Bug.yml b/.github/ISSUE_TEMPLATE/Bug.yml new file mode 100644 index 00000000..1f715088 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug.yml @@ -0,0 +1,64 @@ +name: Bug report +description: Something on Shlink is broken or not working as documented? +labels: ['bug'] +body: + - type: input + validations: + required: true + attributes: + label: Shlink version + placeholder: x.y.z + - type: input + validations: + required: true + attributes: + label: PHP version + placeholder: x.y.z + - type: dropdown + validations: + required: true + attributes: + label: How do you serve Shlink + options: + - Self-hosted Apache + - Self-hosted nginx + - Self-hosted openswoole + - Self-hosted RoadRunner + - Openswoole Docker image + - RoadRunner Docker image + - Other (explain in summary) + - type: dropdown + validations: + required: true + attributes: + label: Database engine + options: + - MySQL + - MariaDB + - PostgreSQL + - MicrosoftSQL + - SQLite + - type: input + validations: + required: true + attributes: + label: Database version + placeholder: x.y.z + - type: textarea + validations: + required: true + attributes: + label: Current behavior + value: '' + - type: textarea + validations: + required: true + attributes: + label: Expected behavior + value: '' + - type: textarea + validations: + required: true + attributes: + label: How to reproduce + value: '' diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.md b/.github/ISSUE_TEMPLATE/Feature_Request.md index 835f705b..dc7fb3d8 100644 --- a/.github/ISSUE_TEMPLATE/Feature_Request.md +++ b/.github/ISSUE_TEMPLATE/Feature_Request.md @@ -4,16 +4,6 @@ about: Do you find Shlink is missing some important feature that would make it m labels: feature --- - - #### Summary From 3f3c2c3d1e06f1f62e0521baaf65e7daa99048e7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 3 Aug 2023 09:08:35 +0200 Subject: [PATCH 37/77] Add form config for Feature Request issues --- .github/ISSUE_TEMPLATE/Feature_Request.md | 9 --------- .github/ISSUE_TEMPLATE/Feature_Request.yml | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/Feature_Request.md create mode 100644 .github/ISSUE_TEMPLATE/Feature_Request.yml diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.md b/.github/ISSUE_TEMPLATE/Feature_Request.md deleted file mode 100644 index dc7fb3d8..00000000 --- a/.github/ISSUE_TEMPLATE/Feature_Request.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: Feature request -about: Do you find Shlink is missing some important feature that would make it more useful? -labels: feature ---- - -#### Summary - - diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.yml b/.github/ISSUE_TEMPLATE/Feature_Request.yml new file mode 100644 index 00000000..4112f75a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_Request.yml @@ -0,0 +1,16 @@ +name: Feature request +description: Do you find Shlink is missing some important feature that would make it more useful? +labels: ['feature'] +body: + - type: textarea + validations: + required: true + attributes: + label: Summary + value: '' + - type: textarea + validations: + required: true + attributes: + label: Use case + value: '' From aa4b9fc27e4d338b0ef8d62aa9e0e8139e7abeff Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 3 Aug 2023 09:10:05 +0200 Subject: [PATCH 38/77] Replace references to docker-compose with docker compose --- CONTRIBUTING.md | 6 +++--- indocker | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79b4c8b4..57173cc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,9 @@ You will also see how to ensure the code fulfills the expected code checks, and ## System dependencies -The project provides all its dependencies as docker containers through a docker-compose configuration. +The project provides all its dependencies as docker containers through a `docker compose` configuration. -Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/). +Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker compose](https://docs.docker.com/compose/install/). ## Setting up the project @@ -21,7 +21,7 @@ Then you will have to follow these steps: For example the `common.local.php.dist` file should be copied as `common.local.php`. * Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension. -* Start-up the project by running `docker-compose up`. +* Start-up the project by running `docker compose up`. The first time this command is run, it will create several containers that are used during development, so it may take some time. diff --git a/indocker b/indocker index 789386ac..7cfbe2c3 100755 --- a/indocker +++ b/indocker @@ -2,7 +2,7 @@ # Run docker containers if they are not up yet if ! [[ $(docker ps | grep shlink_swoole) ]]; then - docker-compose up -d + docker compose up -d fi docker exec -it shlink_swoole /bin/sh -c "$*" From b747b8448eb702d29342390c9d3e3a523c8a19c0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 19 Aug 2023 11:48:04 +0200 Subject: [PATCH 39/77] Update dependencies --- composer.json | 48 +++++++++---------- .../CLI/src/GeoLite/GeolocationDbUpdater.php | 2 +- .../CLI/test-cli/Command/ListApiKeysTest.php | 2 +- module/CLI/test/Util/CliTestUtils.php | 2 +- .../src/Importer/ImportedLinksProcessor.php | 2 +- .../Core/src/Importer/ShortUrlImporting.php | 2 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 4 +- .../Importer/ImportedLinksProcessorTest.php | 6 +-- .../test/ShortUrl/ShortUrlResolverTest.php | 6 +-- module/Rest/src/Entity/ApiKey.php | 2 +- .../test-api/Action/CreateShortUrlTest.php | 4 +- .../Rest/test-api/Action/EditShortUrlTest.php | 10 ++-- .../test-api/Action/NonOrphanVisitsTest.php | 2 +- .../test-api/Action/ResolveShortUrlTest.php | 4 +- .../Rest/test-api/Fixtures/ApiKeyFixture.php | 2 +- .../Rest/test/Service/ApiKeyServiceTest.php | 2 +- 16 files changed, 50 insertions(+), 50 deletions(-) diff --git a/composer.json b/composer.json index 9aa0ed98..134885d5 100644 --- a/composer.json +++ b/composer.json @@ -18,30 +18,30 @@ "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", - "cakephp/chronos": "^2.3", - "doctrine/migrations": "^3.5", - "doctrine/orm": "^2.14", - "endroid/qr-code": "^4.7", + "cakephp/chronos": "^2.4", + "doctrine/migrations": "^3.6", + "doctrine/orm": "^2.16", + "endroid/qr-code": "^4.8", + "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^2.13", "guzzlehttp/guzzle": "^7.5", "happyr/doctrine-specification": "^2.0", - "jaybizzle/crawler-detect": "^1.2.112", + "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.8", "laminas/laminas-config-aggregator": "^1.13", - "laminas/laminas-diactoros": "^2.24", - "laminas/laminas-inputfilter": "^2.24", + "laminas/laminas-diactoros": "^2.25", + "laminas/laminas-inputfilter": "^2.27", "laminas/laminas-servicemanager": "^3.21", - "laminas/laminas-stdlib": "^3.16", + "laminas/laminas-stdlib": "^3.17", "league/uri": "^6.8", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.15", - "mezzio/mezzio-fastroute": "^3.8", - "mezzio/mezzio-problem-details": "^1.11", - "mezzio/mezzio-swoole": "^4.6", + "mezzio/mezzio": "^3.17", + "mezzio/mezzio-fastroute": "^3.10", + "mezzio/mezzio-problem-details": "^1.12", + "mezzio/mezzio-swoole": "^4.7", "mlocati/ip-lib": "^1.18", "mobiledetect/mobiledetectlib": "^3.74", - "ocramius/proxy-manager": "^2.14", - "pagerfanta/core": "^3.7", + "pagerfanta/core": "^3.8", "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", @@ -52,15 +52,15 @@ "shlinkio/shlink-installer": "dev-develop#b393e6b as 8.5", "shlinkio/shlink-ip-geolocation": "^3.2", "shlinkio/shlink-json": "^1.0", - "spiral/roadrunner": "^2023.1", + "spiral/roadrunner": "^2023.2", "spiral/roadrunner-cli": "^2.5", - "spiral/roadrunner-http": "^3.0", + "spiral/roadrunner-http": "^3.1", "spiral/roadrunner-jobs": "^4.0", - "symfony/console": "^6.2", - "symfony/filesystem": "^6.2", - "symfony/lock": "^6.2", - "symfony/process": "^6.2", - "symfony/string": "^6.2" + "symfony/console": "^6.3", + "symfony/filesystem": "^6.3", + "symfony/lock": "^6.3", + "symfony/process": "^6.3", + "symfony/string": "^6.3" }, "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", @@ -71,12 +71,12 @@ "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.3", - "phpunit/php-code-coverage": "^10.0", - "phpunit/phpunit": "^10.2.0", + "phpunit/php-code-coverage": "^10.1", + "phpunit/phpunit": "^10.3", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^3.7.1", - "symfony/var-dumper": "^6.2", + "symfony/var-dumper": "^6.3", "veewee/composer-run-parallel": "^1.2" }, "autoload": { diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index f33b8796..e8f93b19 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -70,7 +70,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface $buildTimestamp = $this->resolveBuildTimestamp($meta); $buildDate = Chronos::createFromTimestamp($buildTimestamp); - return Chronos::now()->gt($buildDate->addDays(35)); + return Chronos::now()->greaterThan($buildDate->addDays(35)); } private function resolveBuildTimestamp(Metadata $meta): int diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php index 633cf819..46e3c135 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -24,7 +24,7 @@ class ListApiKeysTest extends CliTestCase public static function provideFlags(): iterable { - $expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString(); + $expiredApiKeyDate = Chronos::now()->subDays(1)->startOfDay()->toAtomString(); $enabledOnlyOutput = <<getDate()->gte(normalizeDate($importedOrphanVisit->date))) { + if ($mostRecentOrphanVisit?->getDate()->greaterThanOrEquals(normalizeDate($importedOrphanVisit->date))) { continue; } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index 28c22a24..f806f856 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -38,7 +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?->gte(normalizeDate($importedVisit->date))) { + if ($mostRecentImportedDate?->greaterThanOrEquals(normalizeDate($importedVisit->date))) { continue; } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e5646bd4..8fbec5ed 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -319,12 +319,12 @@ class ShortUrl extends AbstractEntity } $now = Chronos::now(); - $beforeValidSince = $this->validSince !== null && $this->validSince->gt($now); + $beforeValidSince = $this->validSince !== null && $this->validSince->greaterThan($now); if ($beforeValidSince) { return false; } - $afterValidUntil = $this->validUntil !== null && $this->validUntil->lt($now); + $afterValidUntil = $this->validUntil !== null && $this->validUntil->lessThan($now); if ($afterValidUntil) { return false; } diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index ff8eebc6..bf2896e2 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -307,9 +307,9 @@ class ImportedLinksProcessorTest extends TestCase yield 'existing orphan visit' => [true, [ new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(3), '', '', null), new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(2), '', '', null), - new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), - new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), - new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDays(1), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDays(1), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDays(1), '', '', null), ], Visit::forBasePath(Visitor::botInstance()), 3]; } diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 86aa56e7..4057691b 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -121,15 +121,15 @@ class ShortUrlResolverTest extends TestCase return $shortUrl; })()]; yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => 'https://longUrl'], + ['validSince' => $now->addMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl'], ))]; yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => 'https://longUrl'], + ['validUntil' => $now->subMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl'], ))]; yield 'mixed' => [(function () use ($now) { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => 3, - 'validUntil' => $now->subMonth()->toAtomString(), + 'validUntil' => $now->subMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl', ])); $shortUrl->setVisits(new ArrayCollection(map( diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 72977c86..bb2372f8 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -65,7 +65,7 @@ class ApiKey extends AbstractEntity public function isExpired(): bool { - return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now()); + return $this->expirationDate !== null && $this->expirationDate->lessThan(Chronos::now()); } public function name(): ?string diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 5b22e79a..78f738a3 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -115,7 +115,7 @@ class CreateShortUrlTest extends ApiTestCase public function createsShortUrlWithValidSince(): void { [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([ - 'validSince' => Chronos::now()->addDay()->toAtomString(), + 'validSince' => Chronos::now()->addDays(1)->toAtomString(), ]); self::assertEquals(self::STATUS_OK, $statusCode); @@ -129,7 +129,7 @@ class CreateShortUrlTest extends ApiTestCase public function createsShortUrlWithValidUntil(): void { [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([ - 'validUntil' => Chronos::now()->subDay()->toAtomString(), + 'validUntil' => Chronos::now()->subDays(1)->toAtomString(), ]); self::assertEquals(self::STATUS_OK, $statusCode); diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index befb5131..a55fb066 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -55,13 +55,13 @@ class EditShortUrlTest extends ApiTestCase { $now = Chronos::now(); - yield [['validSince' => $now->addMonth()->toAtomString()]]; - yield [['validUntil' => $now->subMonth()->toAtomString()]]; + yield [['validSince' => $now->addMonths(1)->toAtomString()]]; + yield [['validUntil' => $now->subMonths(1)->toAtomString()]]; yield [['maxVisits' => 20]]; - yield [['validUntil' => $now->addYear()->toAtomString(), 'maxVisits' => 100]]; + yield [['validUntil' => $now->addYears(1)->toAtomString(), 'maxVisits' => 100]]; yield [[ - 'validSince' => $now->subYear()->toAtomString(), - 'validUntil' => $now->addYear()->toAtomString(), + 'validSince' => $now->subYears(1)->toAtomString(), + 'validUntil' => $now->addYears(1)->toAtomString(), 'maxVisits' => 100, ]]; } diff --git a/module/Rest/test-api/Action/NonOrphanVisitsTest.php b/module/Rest/test-api/Action/NonOrphanVisitsTest.php index f4f7601c..0e69db54 100644 --- a/module/Rest/test-api/Action/NonOrphanVisitsTest.php +++ b/module/Rest/test-api/Action/NonOrphanVisitsTest.php @@ -30,6 +30,6 @@ class NonOrphanVisitsTest extends ApiTestCase yield 'last page' => [['page' => 3, 'itemsPerPage' => 3], 7, 1]; yield 'bots excluded' => [['excludeBots' => 'true'], 6, 6]; yield 'bots excluded and pagination' => [['excludeBots' => 'true', 'page' => 1, 'itemsPerPage' => 4], 6, 4]; - yield 'date filter' => [['startDate' => Chronos::now()->addDay()->toAtomString()], 0, 0]; + yield 'date filter' => [['startDate' => Chronos::now()->addDays(1)->toAtomString()], 0, 0]; } } diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index b13de872..c10abc74 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -37,8 +37,8 @@ class ResolveShortUrlTest extends ApiTestCase { $now = Chronos::now(); - yield 'future validSince' => [['validSince' => $now->addMonth()->toAtomString()]]; - yield 'past validUntil' => [['validUntil' => $now->subMonth()->toAtomString()]]; + yield 'future validSince' => [['validSince' => $now->addMonths(1)->toAtomString()]]; + yield 'past validUntil' => [['validUntil' => $now->subMonths(1)->toAtomString()]]; yield 'maxVisits reached' => [['maxVisits' => 1]]; } diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index ef971d63..86d2cf2a 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -28,7 +28,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface $manager->persist($this->buildApiKey( 'expired_api_key', enabled: true, - expiresAt: Chronos::now()->subDay()->startOfDay(), + expiresAt: Chronos::now()->subDays(1)->startOfDay(), )); $authorApiKey = $this->buildApiKey('author_api_key', enabled: true); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 325713be..8fc0ea2c 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -81,7 +81,7 @@ class ApiKeyServiceTest extends TestCase { yield 'non-existent api key' => [null]; yield 'disabled api key' => [ApiKey::create()->disable()]; - yield 'expired api key' => [ApiKey::fromMeta(ApiKeyMeta::withExpirationDate(Chronos::now()->subDay()))]; + yield 'expired api key' => [ApiKey::fromMeta(ApiKeyMeta::withExpirationDate(Chronos::now()->subDays(1)))]; } #[Test] From 5839cc5926f9144c118f7e261d07f7f1d6dd66c5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 17 Sep 2023 11:59:23 +0200 Subject: [PATCH 40/77] Add a swagger ui container for dev env --- CONTRIBUTING.md | 6 ++++++ docker-compose.yml | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57173cc2..d21c577a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,12 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, * 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, parallelizing non-conflicting tasks as much as possible. +## Testing endpoints + +The project provides a Swagger UI container for dev envs, which can be accessed in http://localhost:8005. + +It will automatically load the contents of `docs/swagger`, so you can make any updates and they will get reflected. + ## Pull request process **Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first. diff --git a/docker-compose.yml b/docker-compose.yml index ca0064b4..b83b5f6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: shlink_nginx: container_name: shlink_nginx - image: nginx:1.19.6-alpine + image: nginx:1.25-alpine ports: - "8000:80" volumes: @@ -40,7 +40,7 @@ services: shlink_swoole_proxy: container_name: shlink_swoole_proxy - image: nginx:1.19.6-alpine + image: nginx:1.25-alpine ports: - "8002:80" volumes: @@ -164,7 +164,7 @@ services: shlink_mercure_proxy: container_name: shlink_mercure_proxy - image: nginx:1.19.6-alpine + image: nginx:1.25-alpine ports: - "8001:80" volumes: @@ -175,7 +175,7 @@ services: shlink_mercure: container_name: shlink_mercure - image: dunglas/mercure:v0.14 + image: dunglas/mercure:v0.15 ports: - "3080:80" environment: @@ -186,10 +186,18 @@ services: shlink_rabbitmq: container_name: shlink_rabbitmq - image: rabbitmq:3.9-management-alpine + image: rabbitmq:3.11-management-alpine ports: - "15672:15672" - "5672:5672" environment: RABBITMQ_DEFAULT_USER: "rabbit" RABBITMQ_DEFAULT_PASS: "rabbit" + + shlink_swagger_ui: + container_name: shlink_swagger_ui + image: swaggerapi/swagger-ui:v5.7.1 + ports: + - "8005:8080" + volumes: + - ./docs/swagger:/app From 27c48414da8a9b7276745dd265efd2d684fb4733 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Sep 2023 21:01:29 +0200 Subject: [PATCH 41/77] Update to chronos 3 --- CHANGELOG.md | 1 + composer.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afbd84d4..cddeac55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1835](https://github.com/shlinkio/shlink/issues/1835) Docker image is now built only when a release is tagged, and new tags are included, for minor and major versions. * [#1055](https://github.com/shlinkio/shlink/issues/1055) Update OAS definition to v3.1. +* [#1885](https://github.com/shlinkio/shlink/issues/1885) Update to chronos 3.0. ### Deprecated * [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. diff --git a/composer.json b/composer.json index 769bdbdd..4b0bfc61 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", - "cakephp/chronos": "^2.4", + "cakephp/chronos": "^3.0.2", "doctrine/migrations": "^3.6", "doctrine/orm": "^2.16", "endroid/qr-code": "^4.8", @@ -45,7 +45,7 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "^5.6", + "shlinkio/shlink-common": "dev-main#8253378 as 5.7", "shlinkio/shlink-config": "dev-main#c0aa01f as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", From 9d8ea0a4f63032d9f905012dabd6f74d0beaf997 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 6 Oct 2023 09:19:55 +0200 Subject: [PATCH 42/77] Allow cache namespace to be customizable via env var --- composer.json | 2 +- config/autoload/cache.global.php | 2 +- config/autoload/installer.global.php | 1 + module/Core/src/Config/EnvVars.php | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4b0bfc61..abc31b1b 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "shlinkio/shlink-config": "dev-main#c0aa01f as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", - "shlinkio/shlink-installer": "^8.5", + "shlinkio/shlink-installer": "dev-develop#a665623 as 8.6", "shlinkio/shlink-ip-geolocation": "^3.2", "shlinkio/shlink-json": "^1.0", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php index 614b140f..94a9a183 100644 --- a/config/autoload/cache.global.php +++ b/config/autoload/cache.global.php @@ -16,7 +16,7 @@ return (static function (): array { return [ 'cache' => [ - 'namespace' => 'Shlink', + 'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv('Shlink'), ...$cacheRedisBlock, ], 'redis' => $redis, diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 966e36ee..0aa849e0 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -28,6 +28,7 @@ return [ Option\Visit\VisitsThresholdConfigOption::class, Option\BasePathConfigOption::class, Option\TimezoneConfigOption::class, + Option\Cache\CacheNamespaceConfigOption::class, Option\Worker\TaskWorkerNumConfigOption::class, Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index ec7d384c..1a917928 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -17,6 +17,7 @@ enum EnvVars: string case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET'; case DB_PORT = 'DB_PORT'; case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY'; + case CACHE_NAMESPACE = 'CACHE_NAMESPACE'; case REDIS_SERVERS = 'REDIS_SERVERS'; case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED'; From 57053d66a467f361c57026620bc490a5f21d1df4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 6 Oct 2023 09:21:53 +0200 Subject: [PATCH 43/77] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cddeac55..26e141e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this When trying to delete orphan visits the result will also be `0` and no visits will actually get deleted. +* [#1879](https://github.com/shlinkio/shlink/issues/1879) Cache namespace can now be customized via config option or `CACHE_NAMESPACE` env var. + + This is important if you are running multiple Shlink instance on the same server, or they share the same Redis instance (even more so if they are on different versions). + ### Changed * [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled. From 3bdc05fbc44d9d13a06cb7dd98e5c17acb2d9159 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Oct 2023 10:56:04 +0200 Subject: [PATCH 44/77] Fix CliTestUtils for PHPUnit 10.4 --- composer.json | 2 +- module/CLI/test/Util/CliTestUtils.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index abc31b1b..4f8ca4df 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,7 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.3", "phpunit/php-code-coverage": "^10.1", - "phpunit/phpunit": "^10.3", + "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^3.7.1", diff --git a/module/CLI/test/Util/CliTestUtils.php b/module/CLI/test/Util/CliTestUtils.php index 62dd6e4f..9c92f882 100644 --- a/module/CLI/test/Util/CliTestUtils.php +++ b/module/CLI/test/Util/CliTestUtils.php @@ -22,8 +22,9 @@ class CliTestUtils $generator = new Generator(); } - $command = $generator->getMock( + $command = $generator->testDouble( Command::class, + mockObject: true, callOriginalConstructor: false, callOriginalClone: false, cloneArguments: false, From 55885b0f259651944439cbcf1f63d3e8454bff36 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Oct 2023 09:26:09 +0200 Subject: [PATCH 45/77] Do not log requests to the health endpoint --- composer.json | 2 +- module/Core/config/dependencies.config.php | 1 - module/Rest/config/acess-logs.config.php | 27 ++++++++++++++++++++++ module/Rest/src/Action/HealthAction.php | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 module/Rest/config/acess-logs.config.php diff --git a/composer.json b/composer.json index 4f8ca4df..34168f62 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#8253378 as 5.7", + "shlinkio/shlink-common": "dev-main#4e859a7 as 5.7", "shlinkio/shlink-config": "dev-main#c0aa01f as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", "shlinkio/shlink-importer": "^5.1", diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index da653406..a245b10e 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -9,7 +9,6 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory; -use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; diff --git a/module/Rest/config/acess-logs.config.php b/module/Rest/config/acess-logs.config.php new file mode 100644 index 00000000..1f0dd0e8 --- /dev/null +++ b/module/Rest/config/acess-logs.config.php @@ -0,0 +1,27 @@ + [ + 'ignored_paths' => [ + Action\HealthAction::ROUTE_PATH, + ], + ], + + // This config needs to go in this file in order to override the value defined in shlink-common + ConfigAbstractFactory::class => [ + // Use MergeReplaceKey to overwrite what was defined in shlink-common, instead of merging it + AccessLogMiddleware::class => new MergeReplaceKey( + [AccessLogMiddleware::LOGGER_SERVICE_NAME, 'config.access_logs.ignored_paths'], + ), + ], + +]; diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index f3bfea98..809bf4d1 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -17,7 +17,7 @@ class HealthAction extends AbstractRestAction private const STATUS_PASS = 'pass'; private const STATUS_FAIL = 'fail'; - protected const ROUTE_PATH = '/health'; + public const ROUTE_PATH = '/health'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct(private EntityManagerInterface $em, private AppOptions $options) From 41e322fd47547072f995b3e86aab1438a4152912 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Oct 2023 09:34:20 +0200 Subject: [PATCH 46/77] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e141e0..67e8a6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1835](https://github.com/shlinkio/shlink/issues/1835) Docker image is now built only when a release is tagged, and new tags are included, for minor and major versions. * [#1055](https://github.com/shlinkio/shlink/issues/1055) Update OAS definition to v3.1. * [#1885](https://github.com/shlinkio/shlink/issues/1885) Update to chronos 3.0. +* [#1896](https://github.com/shlinkio/shlink/issues/1896) Requests to health endpoint are no longer logged. ### Deprecated * [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. From 5bde273d5981eb5a8786e2d4b285770955644201 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Oct 2023 09:42:48 +0200 Subject: [PATCH 47/77] Fix Rest's ConfigProvider test --- module/Core/src/Model/DeviceType.php | 2 +- .../config/{acess-logs.config.php => access-logs.config.php} | 0 module/Rest/test/ConfigProviderTest.php | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) rename module/Rest/config/{acess-logs.config.php => access-logs.config.php} (100%) diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php index e394716a..3b6d9fcc 100644 --- a/module/Core/src/Model/DeviceType.php +++ b/module/Core/src/Model/DeviceType.php @@ -12,7 +12,7 @@ enum DeviceType: string public static function matchFromUserAgent(string $userAgent): ?self { - $detect = new MobileDetect(userAgent: $userAgent); // @phpstan-ignore-line + $detect = new MobileDetect(userAgent: $userAgent); return match (true) { // $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only diff --git a/module/Rest/config/acess-logs.config.php b/module/Rest/config/access-logs.config.php similarity index 100% rename from module/Rest/config/acess-logs.config.php rename to module/Rest/config/access-logs.config.php diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 72063a72..305654b3 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -24,10 +24,11 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(4, $config); + self::assertCount(5, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('auth', $config); self::assertArrayHasKey('entity_manager', $config); + self::assertArrayHasKey('access_logs', $config); self::assertArrayHasKey(ConfigAbstractFactory::class, $config); } From 2cb8486bb322d719e975ce61acc8269d14774323 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Nov 2023 12:42:31 +0100 Subject: [PATCH 48/77] Add support for PHP 8.3 --- .github/actions/ci-setup/action.yml | 2 +- .github/workflows/ci-db-tests.yml | 5 +++-- .github/workflows/ci-docker-image-build.yml | 2 +- .github/workflows/ci-mutation-tests.yml | 5 +++-- .github/workflows/ci-tests.yml | 5 +++-- .github/workflows/ci.yml | 11 ++++++----- .github/workflows/publish-release.yml | 4 ++-- .github/workflows/publish-swagger-spec.yml | 2 +- CHANGELOG.md | 2 ++ composer.json | 16 ++++++++-------- 10 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 19df378a..2771d3f8 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -43,5 +43,5 @@ runs: ini-values: pcov.directory=module - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist + run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-req=php' || '' }} shell: bash diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index f0fc3eaa..c2bfe8d9 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -13,11 +13,12 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.2'] + php-version: ['8.2', '8.3'] + continue-on-error: ${{ matrix.php-version == '8.3' }} env: LC_ALL: C steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install MSSQL ODBC if: ${{ inputs.platform == 'ms' }} run: sudo ./data/infra/ci/install-ms-odbc.sh diff --git a/.github/workflows/ci-docker-image-build.yml b/.github/workflows/ci-docker-image-build.yml index 3a055f10..43812fad 100644 --- a/.github/workflows/ci-docker-image-build.yml +++ b/.github/workflows/ci-docker-image-build.yml @@ -10,5 +10,5 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - run: docker build -t shlink-docker-image:temp . diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index a0facf09..7ee91941 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -13,9 +13,10 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.2'] + php-version: ['8.2', '8.3'] + continue-on-error: ${{ matrix.php-version == '8.3' }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index e407da70..d606e252 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -13,9 +13,10 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.2'] + php-version: ['8.2', '8.3'] + continue-on-error: ${{ matrix.php-version == '8.3' }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Start postgres database server if: ${{ inputs.test-group == 'api' }} run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f19904d..54eb6460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: php-version: ['8.2'] command: ['cs', 'stan', 'swagger:validate'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} @@ -59,17 +59,18 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.2'] + php-version: ['8.2', '8.3'] + continue-on-error: ${{ matrix.php-version == '8.3' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer - - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole + - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-req=php' || '' }} - run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - run: composer test:api:rr @@ -138,7 +139,7 @@ jobs: php-version: ['8.2'] steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Use PHP uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 66c24528..47ba7538 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -13,7 +13,7 @@ jobs: php-version: ['8.2'] swoole: ['yes', 'no'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} @@ -33,7 +33,7 @@ jobs: needs: ['build'] runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: path: build diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 094e8461..b18eda20 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -12,7 +12,7 @@ jobs: matrix: php-version: ['8.2'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Determine version id: determine_version run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e8a6b5..2510b208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is important if you are running multiple Shlink instance on the same server, or they share the same Redis instance (even more so if they are on different versions). +* [#1905](https://github.com/shlinkio/shlink/issues/1905) Add support for PHP 8.3. + ### Changed * [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled. diff --git a/composer.json b/composer.json index 34168f62..aca4464e 100644 --- a/composer.json +++ b/composer.json @@ -45,13 +45,13 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#4e859a7 as 5.7", - "shlinkio/shlink-config": "dev-main#c0aa01f as 2.5", - "shlinkio/shlink-event-dispatcher": "dev-main#bd3a62b as 3.1", - "shlinkio/shlink-importer": "^5.1", - "shlinkio/shlink-installer": "dev-develop#a665623 as 8.6", - "shlinkio/shlink-ip-geolocation": "^3.2", - "shlinkio/shlink-json": "^1.0", + "shlinkio/shlink-common": "dev-main#7d46772 as 5.7", + "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", + "shlinkio/shlink-event-dispatcher": "dev-main#faf2582 as 3.1", + "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", + "shlinkio/shlink-installer": "dev-develop#c1ef08c as 8.6", + "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", + "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", "spiral/roadrunner": "^2023.2", "spiral/roadrunner-cli": "^2.5", "spiral/roadrunner-http": "^3.1", @@ -75,7 +75,7 @@ "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.7.1", + "shlinkio/shlink-test-utils": "dev-main#cbbb64e as 3.8.0", "symfony/var-dumper": "^6.3", "veewee/composer-run-parallel": "^1.2" }, From 4cddb573a0f035db9e1bb1766b6a48fcdbf60107 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Nov 2023 13:03:10 +0100 Subject: [PATCH 49/77] Ignore all platform reqs on PHP 8.3, as openswoole cannot be installed there --- .github/actions/ci-setup/action.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 2771d3f8..054575eb 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -43,5 +43,5 @@ runs: ini-values: pcov.directory=module - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-req=php' || '' }} + run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-reqs' || '' }} shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54eb6460..3953aeec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-req=php' || '' }} + - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-reqs' || '' }} - run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - run: composer test:api:rr From 32f465f7a63d1f278caf93444ff15a15788e56ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Nov 2023 13:15:15 +0100 Subject: [PATCH 50/77] Add PHP 8.3 to building pipeline --- .github/workflows/publish-release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 47ba7538..be34a9a1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,8 +10,13 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.2'] + php-version: ['8.2', '8.3'] swoole: ['yes', 'no'] + exclude: + # Openswoole does not support PHP 8.3, so lets not build for that combination + # Next Shlink version (4.0.0) is completely dropping support for openswoole and we will be able to remove this + - php-version: '8.3' + swoole: 'yes' steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' From cfc3d541229cb572c8fa8aee12dbf4c1bbc0fb9d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Nov 2023 10:30:40 +0100 Subject: [PATCH 51/77] Do not allow URL reserved characters in custom slugs --- composer.json | 2 +- .../Core/src/Options/UrlShortenerOptions.php | 4 +- .../Model/Validation/CustomSlugValidator.php | 63 +++++++++++++++ .../Model/Validation/ShortUrlInputFilter.php | 12 +-- .../ShortUrl/Model/ShortUrlCreationTest.php | 4 + .../Validation/CustomSlugValidatorTest.php | 77 +++++++++++++++++++ 6 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php create mode 100644 module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php diff --git a/composer.json b/composer.json index aca4464e..8e42b1d0 100644 --- a/composer.json +++ b/composer.json @@ -77,7 +77,7 @@ "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "dev-main#cbbb64e as 3.8.0", "symfony/var-dumper": "^6.3", - "veewee/composer-run-parallel": "^1.2" + "veewee/composer-run-parallel": "^1.3" }, "autoload": { "psr-4": { diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 32b40033..03091d75 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -10,8 +10,10 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class UrlShortenerOptions { + /** + * @param array{schema: ?string, hostname: ?string} $domain + */ public function __construct( - /** @var array{schema: ?string, hostname: ?string} */ public readonly array $domain = ['schema' => null, 'hostname' => null], public readonly int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH, public readonly bool $autoResolveTitles = false, diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php new file mode 100644 index 00000000..24afc72e --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php @@ -0,0 +1,63 @@ + 'Provided value is not a string.', + self::CONTAINS_URL_CHARACTERS => 'URL-reserved characters cannot be used in a custom slug.', + ]; + + private UrlShortenerOptions $options; + + private function __construct() + { + parent::__construct(); + } + + public static function forUrlShortenerOptions(UrlShortenerOptions $options): self + { + $instance = new self(); + $instance->options = $options; + + return $instance; + } + + public function isValid(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (! is_string($value)) { + $this->error(self::NOT_STRING); + return false; + } + + // URL reserved characters: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + $reservedChars = "!*'();:@&=+$,?%#[]"; + if (! $this->options->multiSegmentSlugsEnabled) { + // Slashes should be allowed for multi-segment slugs + $reservedChars .= '/'; + } + + if (strpbrk($value, $reservedChars) !== false) { + $this->error(self::CONTAINS_URL_CHARACTERS); + return false; + } + + return true; + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index af7e8986..23ac8a2f 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -81,13 +81,15 @@ class ShortUrlInputFilter extends InputFilter $this->add($validUntil); // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value - // is by using the deprecated setContinueIfEmpty + // is with setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); - $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ - Validator\NotEmpty::STRING, - Validator\NotEmpty::SPACE, - ])); + $customSlug->getValidatorChain() + ->attach(new Validator\NotEmpty([ + Validator\NotEmpty::STRING, + Validator\NotEmpty::SPACE, + ])) + ->attach(CustomSlugValidator::forUrlShortenerOptions($options)); $this->add($customSlug); $this->add($this->createNumericInput(self::MAX_VISITS, false)); diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 401a3a31..47d4648c 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -62,6 +62,10 @@ class ShortUrlCreationTest extends TestCase ShortUrlInputFilter::LONG_URL => 'https://foo', ShortUrlInputFilter::CUSTOM_SLUG => '', ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'https://foo', + ShortUrlInputFilter::CUSTOM_SLUG => 'foo?some=param', + ]]; yield [[ ShortUrlInputFilter::LONG_URL => 'https://foo', ShortUrlInputFilter::CUSTOM_SLUG => ' ', diff --git a/module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php new file mode 100644 index 00000000..290fe63d --- /dev/null +++ b/module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php @@ -0,0 +1,77 @@ +createValidator(); + self::assertTrue($validator->isValid(null)); + } + + #[Test, DataProvider('provideNonStringValues')] + public function nonStringValuesAreInvalid(mixed $value): void + { + $validator = $this->createValidator(); + + self::assertFalse($validator->isValid($value)); + self::assertEquals(['NOT_STRING' => 'Provided value is not a string.'], $validator->getMessages()); + } + + public static function provideNonStringValues(): iterable + { + yield [123]; + yield [new stdClass()]; + yield [true]; + } + + #[Test] + public function slashesAreAllowedWhenMultiSegmentSlugsAreEnabled(): void + { + $slugWithSlashes = 'foo/bar/baz'; + + self::assertTrue($this->createValidator(multiSegmentSlugsEnabled: true)->isValid($slugWithSlashes)); + self::assertFalse($this->createValidator(multiSegmentSlugsEnabled: false)->isValid($slugWithSlashes)); + } + + #[Test, DataProvider('provideInvalidValues')] + public function valuesWithReservedCharsAreInvalid(string $value): void + { + $validator = $this->createValidator(); + + self::assertFalse($validator->isValid($value)); + self::assertEquals( + ['CONTAINS_URL_CHARACTERS' => 'URL-reserved characters cannot be used in a custom slug.'], + $validator->getMessages(), + ); + } + + public static function provideInvalidValues(): iterable + { + yield ['foo?bar=baz']; + yield ['some-thing#foo']; + yield ['call()']; + yield ['array[]']; + yield ['email@example.com']; + yield ['wildcard*']; + yield ['$500']; + } + + public function createValidator(bool $multiSegmentSlugsEnabled = false): CustomSlugValidator + { + return CustomSlugValidator::forUrlShortenerOptions( + new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentSlugsEnabled), + ); + } +} From e431395a12dac4738b36787ada8ddc8d6d37b9f5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Nov 2023 10:31:51 +0100 Subject: [PATCH 52/77] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2510b208..d0f03e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up. +* [#1901](https://github.com/shlinkio/shlink/issues/1901) Do not allow short URLs with custom slugs containing URL-reserved characters, as they will not work at all afterward. ## [3.6.4] - 2023-09-23 From 85b5f760e56264a6829ec93968b8bae9a83ecd00 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Nov 2023 10:58:41 +0100 Subject: [PATCH 53/77] Update dev swagger UI --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b83b5f6b..a398d4bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -196,7 +196,7 @@ services: shlink_swagger_ui: container_name: shlink_swagger_ui - image: swaggerapi/swagger-ui:v5.7.1 + image: swaggerapi/swagger-ui:v5.9.1 ports: - "8005:8080" volumes: From 8d35c1dde2e2330578c82d4b74b3ea8197a032af Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 8 Nov 2023 09:06:12 +0100 Subject: [PATCH 54/77] Fix short URL visits deletion when multi-segment slugs are enabled --- CHANGELOG.md | 1 + config/autoload/routes.config.php | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f03e72..e667fd19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up. * [#1901](https://github.com/shlinkio/shlink/issues/1901) Do not allow short URLs with custom slugs containing URL-reserved characters, as they will not work at all afterward. +* [#1900](https://github.com/shlinkio/shlink/issues/1900) Fix short URL visits deletion when multi-segment slugs are enabled. ## [3.6.4] - 2023-09-23 diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index ea305d86..051e18dd 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -32,8 +32,11 @@ return (static function (): array { ...ConfigProvider::applyRoutesPrefix([ Action\HealthAction::getRouteDef(), - // Visits + // Visits. + // These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs + // are enabled, routes with a less-specific path might match first Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\TagVisitsAction::getRouteDef(), Action\Visit\DomainVisitsAction::getRouteDef(), Action\Visit\GlobalVisitsAction::getRouteDef(), @@ -54,7 +57,6 @@ return (static function (): array { ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\ListShortUrlsAction::getRouteDef(), From 14c68b4bbee6874bb6408a42b9b04ef6074b80c4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 8 Nov 2023 18:51:03 +0100 Subject: [PATCH 55/77] Update native deps for PHP 8.3 preparation --- .github/workflows/ci-db-tests.yml | 2 +- .github/workflows/ci-mutation-tests.yml | 2 +- .github/workflows/ci-tests.yml | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/publish-release.yml | 7 +------ .github/workflows/publish-swagger-spec.yml | 2 +- Dockerfile | 4 ++-- build.sh | 3 ++- composer.json | 2 +- data/infra/php.Dockerfile | 2 +- data/infra/roadrunner.Dockerfile | 2 +- data/infra/swoole.Dockerfile | 4 ++-- 12 files changed, 15 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index c2bfe8d9..f164e7da 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -28,7 +28,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.0.0, pdo_sqlsrv-5.10.1 + php-extensions: openswoole-22.1.0, pdo_sqlsrv-5.11.1 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index 7ee91941..6bc69eb3 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.0.0 + php-extensions: openswoole-22.1.0 extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index d606e252..62b7ca2e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -26,7 +26,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.0.0 + php-extensions: openswoole-22.1.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3953aeec..37134b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.0.0 + php-extensions: openswoole-22.1.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index be34a9a1..625597a1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -12,17 +12,12 @@ jobs: matrix: php-version: ['8.2', '8.3'] swoole: ['yes', 'no'] - exclude: - # Openswoole does not support PHP 8.3, so lets not build for that combination - # Next Shlink version (4.0.0) is completely dropping support for openswoole and we will be able to remove this - - php-version: '8.3' - swoole: 'yes' steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.0.0 + php-extensions: openswoole-22.1.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - if: ${{ matrix.swoole == 'yes' }} diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index b18eda20..2ecf8d49 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.0.0 + php-extensions: openswoole-22.1.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/Dockerfile b/Dockerfile index 8c5a9486..0916b10b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ARG SHLINK_USER_ID='root' ENV SHLINK_USER_ID ${SHLINK_USER_ID} -ENV OPENSWOOLE_VERSION 22.0.0 -ENV PDO_SQLSRV_VERSION 5.10.1 +ENV OPENSWOOLE_VERSION 22.1.0 +ENV PDO_SQLSRV_VERSION 5.11.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 ENV LC_ALL 'C' diff --git a/build.sh b/build.sh index f91ab408..db607172 100755 --- a/build.sh +++ b/build.sh @@ -31,7 +31,8 @@ cd "${builtContent}" # Install dependencies echo "Installing dependencies with $composerBin..." -composerFlags="--optimize-autoloader --no-progress --no-interaction" +# Deprecated. Do not ignore PHP platform req for Shlink v4.0.0 +composerFlags="--optimize-autoloader --no-progress --no-interaction --ignore-platform-req=php+" ${composerBin} self-update ${composerBin} install --no-dev --prefer-dist $composerFlags diff --git a/composer.json b/composer.json index 8e42b1d0..4f036a17 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "lstrojny/functional-php": "^1.17", "mezzio/mezzio": "^3.17", "mezzio/mezzio-fastroute": "^3.10", - "mezzio/mezzio-problem-details": "^1.12", + "mezzio/mezzio-problem-details": "^1.13", "mezzio/mezzio-swoole": "^4.7", "mlocati/ip-lib": "^1.18", "mobiledetect/mobiledetectlib": "^3.74", diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 90ccab23..14c99f95 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -2,7 +2,7 @@ FROM php:8.2-fpm-alpine3.17 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.10.1 +ENV PDO_SQLSRV_VERSION 5.11.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 457a416f..0e91d491 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -2,7 +2,7 @@ FROM php:8.2-alpine3.17 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.10.1 +ENV PDO_SQLSRV_VERSION 5.11.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 42c27b14..72536c75 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -3,8 +3,8 @@ MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 22.0.0 -ENV PDO_SQLSRV_VERSION 5.10.1 +ENV OPENSWOOLE_VERSION 22.1.0 +ENV PDO_SQLSRV_VERSION 5.11.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 From b4386a3508b3d7b8512407c9c131c9464db3c17e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 4 Jun 2023 12:40:10 +0200 Subject: [PATCH 56/77] Add matomo container --- .gitignore | 1 + config/config.php | 66 +++++++++++++++++++++++++--------------------- docker-compose.yml | 21 +++++++++++++++ 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 283d5b7f..b07b73d1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ vendor/ data/database.sqlite data/shlink-tests.db data/GeoLite2-City.* +data/infra/matomo docs/swagger-ui* docs/mercure.html docker-compose.override.yml diff --git a/config/config.php b/config/config.php index 9df29138..a52ade5a 100644 --- a/config/config.php +++ b/config/config.php @@ -22,33 +22,39 @@ use const PHP_SAPI; $isTestEnv = env('APP_ENV') === 'test'; $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner(); -return (new ConfigAggregator\ConfigAggregator([ - ! $isTestEnv - ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class)) - : new ConfigAggregator\ArrayProvider([]), - Mezzio\ConfigProvider::class, - Mezzio\Router\ConfigProvider::class, - Mezzio\Router\FastRouteRouter\ConfigProvider::class, - $enableSwoole && class_exists(Swoole\ConfigProvider::class) - ? Swoole\ConfigProvider::class - : new ConfigAggregator\ArrayProvider([]), - ProblemDetails\ConfigProvider::class, - Diactoros\ConfigProvider::class, - Common\ConfigProvider::class, - Config\ConfigProvider::class, - Importer\ConfigProvider::class, - IpGeolocation\ConfigProvider::class, - EventDispatcher\ConfigProvider::class, - Core\ConfigProvider::class, - CLI\ConfigProvider::class, - Rest\ConfigProvider::class, - new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'), - // Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests - new ConfigAggregator\PhpFileProvider($isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php'), - // Routes have to be loaded last - new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), -], 'data/cache/app_config.php', [ - Core\Config\PostProcessor\BasePathPrefixer::class, - Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, - Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, -]))->getMergedConfig(); +return (new ConfigAggregator\ConfigAggregator( + providers: [ + ! $isTestEnv + ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class)) + : new ConfigAggregator\ArrayProvider([]), + Mezzio\ConfigProvider::class, + Mezzio\Router\ConfigProvider::class, + Mezzio\Router\FastRouteRouter\ConfigProvider::class, + $enableSwoole && class_exists(Swoole\ConfigProvider::class) + ? Swoole\ConfigProvider::class + : new ConfigAggregator\ArrayProvider([]), + ProblemDetails\ConfigProvider::class, + Diactoros\ConfigProvider::class, + Common\ConfigProvider::class, + Config\ConfigProvider::class, + Importer\ConfigProvider::class, + IpGeolocation\ConfigProvider::class, + EventDispatcher\ConfigProvider::class, + Core\ConfigProvider::class, + CLI\ConfigProvider::class, + Rest\ConfigProvider::class, + new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'), + // Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests + new ConfigAggregator\PhpFileProvider( + $isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php', + ), + // Routes have to be loaded last + new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), + ], + cachedConfigFile: 'data/cache/app_config.php', + postProcessors: [ + Core\Config\PostProcessor\BasePathPrefixer::class, + Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, + Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, + ], +))->getMergedConfig(); diff --git a/docker-compose.yml b/docker-compose.yml index a398d4bc..014764f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq + - shlink_matomo environment: LC_ALL: C extra_hosts: @@ -70,6 +71,7 @@ services: - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq + - shlink_matomo environment: LC_ALL: C extra_hosts: @@ -95,6 +97,7 @@ services: - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq + - shlink_matomo environment: LC_ALL: C extra_hosts: @@ -201,3 +204,21 @@ services: - "8005:8080" volumes: - ./docs/swagger:/app + + shlink_matomo: + container_name: shlink_matomo + image: matomo:4.14-apache + ports: + - "8003:80" + volumes: + # Matomo does not persist port in trusted hosts. This is needed to edit config afterwards + # https://github.com/matomo-org/matomo/issues/9549 + - ./data/infra/matomo:/var/www/html + links: + - shlink_db_mysql + environment: + MATOMO_DATABASE_HOST: "shlink_db_mysql" + MATOMO_DATABASE_ADAPTER: "mysql" + MATOMO_DATABASE_DBNAME: "matomo" + MATOMO_DATABASE_USERNAME: "root" + MATOMO_DATABASE_PASSWORD: "root" From b145d106b030fa2828f02660888719df38c9d695 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 8 Jun 2023 18:41:36 +0200 Subject: [PATCH 57/77] Add matomo env vars and config --- composer.json | 1 + config/autoload/matomo.global.php | 16 ++++++++++++++++ module/Core/src/Config/EnvVars.php | 14 +++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 config/autoload/matomo.global.php diff --git a/composer.json b/composer.json index 4f036a17..8dfeeb73 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "laminas/laminas-stdlib": "^3.17", "league/uri": "^6.8", "lstrojny/functional-php": "^1.17", + "matomo/matomo-php-tracker": "^3.2", "mezzio/mezzio": "^3.17", "mezzio/mezzio-fastroute": "^3.10", "mezzio/mezzio-problem-details": "^1.13", diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php new file mode 100644 index 00000000..3fe1dd00 --- /dev/null +++ b/config/autoload/matomo.global.php @@ -0,0 +1,16 @@ + [ + 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), + 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), + 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), + 'token' => EnvVars::MATOMO_TOKEN->loadFromEnv(), + ], + +]; diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 1a917928..d624c58e 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -24,11 +24,6 @@ enum EnvVars: string case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET'; - case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; - case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; - case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; - case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; - case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case RABBITMQ_ENABLED = 'RABBITMQ_ENABLED'; case RABBITMQ_HOST = 'RABBITMQ_HOST'; case RABBITMQ_PORT = 'RABBITMQ_PORT'; @@ -37,6 +32,15 @@ enum EnvVars: string case RABBITMQ_VHOST = 'RABBITMQ_VHOST'; /** @deprecated */ case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; + case MATOMO_ENABLED = 'MATOMO_ENABLED'; + case MATOMO_BASE_URL = 'MATOMO_BASE_URL'; + case MATOMO_SITE_ID = 'MATOMO_SITE_ID'; + case MATOMO_TOKEN = 'MATOMO_TOKEN'; + case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; + case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; + case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; + case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; + case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; From 7501eca71ed56cbe49c9e563567cd192cf7dd852 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Nov 2023 09:04:41 +0100 Subject: [PATCH 58/77] Update matomo container --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 014764f3..6a8fa2c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -207,7 +207,7 @@ services: shlink_matomo: container_name: shlink_matomo - image: matomo:4.14-apache + image: matomo:4.15-apache ports: - "8003:80" volumes: From 0edb3e5c2c85a7df1f22276ce2b977154345fea1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 11 Nov 2023 20:12:39 +0100 Subject: [PATCH 59/77] Update to installer with support for matomo --- composer.json | 2 +- config/autoload/installer.global.php | 4 ++++ config/autoload/matomo.global.php | 2 +- docker-compose.yml | 2 +- module/Core/src/Config/EnvVars.php | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 8dfeeb73..51e46b75 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#faf2582 as 3.1", "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", - "shlinkio/shlink-installer": "dev-develop#c1ef08c as 8.6", + "shlinkio/shlink-installer": "dev-develop#c505a19 as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 0aa849e0..e48b0ec7 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -66,6 +66,10 @@ return [ Option\RabbitMq\RabbitMqUserConfigOption::class, Option\RabbitMq\RabbitMqPasswordConfigOption::class, Option\RabbitMq\RabbitMqVhostConfigOption::class, + Option\Matomo\MatomoEnabledConfigOption::class, + Option\Matomo\MatomoBaseUrlConfigOption::class, + Option\Matomo\MatomoSiteIdConfigOption::class, + Option\Matomo\MatomoApiTokenConfigOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php index 3fe1dd00..a72d48a4 100644 --- a/config/autoload/matomo.global.php +++ b/config/autoload/matomo.global.php @@ -10,7 +10,7 @@ return [ 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), - 'token' => EnvVars::MATOMO_TOKEN->loadFromEnv(), + 'token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), ], ]; diff --git a/docker-compose.yml b/docker-compose.yml index 6a8fa2c5..5a0b6278 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -211,7 +211,7 @@ services: ports: - "8003:80" volumes: - # Matomo does not persist port in trusted hosts. This is needed to edit config afterwards + # Matomo does not persist port in trusted hosts. This is needed to edit config afterward # https://github.com/matomo-org/matomo/issues/9549 - ./data/infra/matomo:/var/www/html links: diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index d624c58e..c966043f 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -35,7 +35,7 @@ enum EnvVars: string case MATOMO_ENABLED = 'MATOMO_ENABLED'; case MATOMO_BASE_URL = 'MATOMO_BASE_URL'; case MATOMO_SITE_ID = 'MATOMO_SITE_ID'; - case MATOMO_TOKEN = 'MATOMO_TOKEN'; + case MATOMO_API_TOKEN = 'MATOMO_API_TOKEN'; case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; From 9dbd15bc0c020dcf1a0419601a2b38cf97a875dc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 15 Nov 2023 19:57:58 +0100 Subject: [PATCH 60/77] Add logic to send visits to a matomo instance --- config/autoload/matomo.global.php | 2 +- module/Core/config/dependencies.config.php | 5 + .../Core/config/event_dispatcher.config.php | 274 ++++++++++-------- .../Event/AbstractVisitEvent.php | 10 +- .../src/EventDispatcher/Event/UrlVisited.php | 14 - .../Core/src/EventDispatcher/LocateVisit.php | 4 +- .../Matomo/SendVisitToMatomo.php | 88 ++++++ module/Core/src/Matomo/MatomoOptions.php | 27 ++ .../Core/src/Matomo/MatomoTrackerBuilder.php | 39 +++ .../Matomo/MatomoTrackerBuilderInterface.php | 16 + module/Core/src/Visit/Entity/Visit.php | 5 + module/Core/src/Visit/VisitsTracker.php | 2 +- .../test/EventDispatcher/LocateVisitTest.php | 2 +- 13 files changed, 341 insertions(+), 147 deletions(-) create mode 100644 module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php create mode 100644 module/Core/src/Matomo/MatomoOptions.php create mode 100644 module/Core/src/Matomo/MatomoTrackerBuilder.php create mode 100644 module/Core/src/Matomo/MatomoTrackerBuilderInterface.php diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php index a72d48a4..120ad289 100644 --- a/config/autoload/matomo.global.php +++ b/config/autoload/matomo.global.php @@ -10,7 +10,7 @@ return [ 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), - 'token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), + 'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), ], ]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a245b10e..591fcc79 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -92,6 +92,9 @@ return [ Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, Crawling\CrawlingHelper::class => ConfigAbstractFactory::class, + + Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'], + Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -100,6 +103,8 @@ return [ ], ConfigAbstractFactory::class => [ + Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class], + ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index ac8626e8..312e3917 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; @@ -18,152 +19,177 @@ use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -return [ +use function Shlinkio\Shlink\Config\runningInOpenswoole; +use function Shlinkio\Shlink\Config\runningInRoadRunner; - 'events' => [ - 'regular' => [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, - ], - EventDispatcher\Event\GeoLiteDbCreated::class => [ - EventDispatcher\LocateUnlocatedVisits::class, - ], +return (static function (): array { + $regularEvents = [ + EventDispatcher\Event\UrlVisited::class => [ + EventDispatcher\LocateVisit::class, ], - 'async' => [ - EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\Mercure\NotifyVisitToMercure::class, - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, - EventDispatcher\NotifyVisitToWebHooks::class, - EventDispatcher\UpdateGeoLiteDb::class, - ], - EventDispatcher\Event\ShortUrlCreated::class => [ - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, - ], + EventDispatcher\Event\GeoLiteDbCreated::class => [ + EventDispatcher\LocateUnlocatedVisits::class, ], - ], + ]; + $asyncEvents = [ + EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, + EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\UpdateGeoLiteDb::class, + ], + EventDispatcher\Event\ShortUrlCreated::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, + ], + ]; - 'dependencies' => [ - 'factories' => [ - EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, - EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, - EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, - EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, + // Send visits to matomo asynchronously if the runtime allows it + if (runningInRoadRunner() || runningInOpenswoole()) { + $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; + } else { + $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; + } - EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + return [ + + 'events' => [ + 'regular' => $regularEvents, + 'async' => $asyncEvents, ], - 'aliases' => [ - EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, + 'dependencies' => [ + 'factories' => [ + EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, + EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, + EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, + EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, + + EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + ], + + 'aliases' => [ + EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, + ], + + 'delegators' => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\LocateUnlocatedVisits::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\NotifyVisitToWebHooks::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + ], ], - 'delegators' => [ + ConfigAbstractFactory::class => [ + EventDispatcher\LocateVisit::class => [ + IpLocationResolverInterface::class, + 'em', + 'Logger_Shlink', + DbUpdater::class, + EventDispatcherInterface::class, + ], + EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], + EventDispatcher\NotifyVisitToWebHooks::class => [ + 'httpClient', + 'em', + 'Logger_Shlink', + Options\WebhookOptions::class, + ShortUrl\Transformer\ShortUrlDataTransformer::class, + Options\AppOptions::class, + ], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', ], EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', ], EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Visit\Transformer\OrphanVisitDataTransformer::class, + Options\RabbitMqOptions::class, ], EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Options\RabbitMqOptions::class, ], EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], - EventDispatcher\LocateUnlocatedVisits::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + + EventDispatcher\Matomo\SendVisitToMatomo::class => [ + 'em', + 'Logger_Shlink', + ShortUrlStringifier::class, + Matomo\MatomoOptions::class, + Matomo\MatomoTrackerBuilder::class, ], - EventDispatcher\NotifyVisitToWebHooks::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + + EventDispatcher\UpdateGeoLiteDb::class => [ + GeolocationDbUpdater::class, + 'Logger_Shlink', + EventDispatcherInterface::class, + ], + + EventDispatcher\Helper\EnabledListenerChecker::class => [ + Options\RabbitMqOptions::class, + 'config.redis.pub_sub_enabled', + MercureOptions::class, + Options\WebhookOptions::class, + GeoLite2Options::class, ], ], - ], - ConfigAbstractFactory::class => [ - EventDispatcher\LocateVisit::class => [ - IpLocationResolverInterface::class, - 'em', - 'Logger_Shlink', - DbUpdater::class, - EventDispatcherInterface::class, - ], - EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], - EventDispatcher\NotifyVisitToWebHooks::class => [ - 'httpClient', - 'em', - 'Logger_Shlink', - Options\WebhookOptions::class, - ShortUrl\Transformer\ShortUrlDataTransformer::class, - Options\AppOptions::class, - ], - EventDispatcher\Mercure\NotifyVisitToMercure::class => [ - MercureHubPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - ], - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ - MercureHubPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - ], - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ - RabbitMqPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - Visit\Transformer\OrphanVisitDataTransformer::class, - Options\RabbitMqOptions::class, - ], - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ - RabbitMqPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - Options\RabbitMqOptions::class, - ], - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ - RedisPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - 'config.redis.pub_sub_enabled', - ], - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ - RedisPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - 'config.redis.pub_sub_enabled', - ], - EventDispatcher\UpdateGeoLiteDb::class => [ - GeolocationDbUpdater::class, - 'Logger_Shlink', - EventDispatcherInterface::class, - ], - - EventDispatcher\Helper\EnabledListenerChecker::class => [ - Options\RabbitMqOptions::class, - 'config.redis.pub_sub_enabled', - MercureOptions::class, - Options\WebhookOptions::class, - GeoLite2Options::class, - ], - ], - -]; + ]; +})(); diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 907b3d9c..87f7dba2 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -9,17 +9,19 @@ use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable; abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable { - final public function __construct(public readonly string $visitId) - { + final public function __construct( + public readonly string $visitId, + public readonly ?string $originalIpAddress = null, + ) { } public function jsonSerialize(): array { - return ['visitId' => $this->visitId]; + return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; } public static function fromPayload(array $payload): self { - return new static($payload['visitId'] ?? ''); + return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); } } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index c57d59d6..d1158a4e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,18 +6,4 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; final class UrlVisited extends AbstractVisitEvent { - private ?string $originalIpAddress = null; - - public static function withOriginalIpAddress(string $visitId, ?string $originalIpAddress): self - { - $instance = new self($visitId); - $instance->originalIpAddress = $originalIpAddress; - - return $instance; - } - - public function originalIpAddress(): ?string - { - return $this->originalIpAddress; - } } diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index ba3ac3f0..f139c0f5 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -41,8 +41,8 @@ class LocateVisit return; } - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); - $this->eventDispatcher->dispatch(new VisitLocated($visitId)); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); + $this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress)); } private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php new file mode 100644 index 00000000..4e0bcb86 --- /dev/null +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -0,0 +1,88 @@ +matomoOptions->enabled) { + return; + } + + $visitId = $visitLocated->visitId; + + /** @var Visit|null $visit */ + $visit = $this->em->find(Visit::class, $visitId); + if ($visit === null) { + $this->logger->warning('Tried to send visit with id "{visitId}" to matomo, but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + try { + $tracker = $this->trackerBuilder->buildMatomoTracker(); + + $tracker + ->setUrl($this->resolveUrlToTrack($visit)) + ->setCustomTrackingParameter('type', $visit->type()->value) + ->setUserAgent($visit->userAgent()); + + $location = $visit->getVisitLocation(); + if ($location !== null) { + $tracker + ->setCity($location->getCityName()) + ->setCountry($location->getCountryName()) + ->setLatitude($location->getLatitude()) + ->setLongitude($location->getLongitude()); + } + + // Set not obfuscated IP if possible, as matomo handles obfuscation itself + $ip = $visitLocated->originalIpAddress ?? $visit->getRemoteAddr(); + if ($ip !== null) { + $tracker->setIp($ip); + } + + if ($visit->isOrphan()) { + $tracker->setCustomTrackingParameter('orphan', 'true'); + } + + // Send empty document title to avoid different actions to be created by matomo + $tracker->doTrackPageView(''); + } catch (Throwable $e) { + // Capture all exceptions to make sure this does not interfere with the regular execution + $this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]); + } + } + + public function resolveUrlToTrack(Visit $visit): string + { + $shortUrl = $visit->getShortUrl(); + if ($shortUrl === null) { + return $visit->visitedUrl() ?? ''; + } + + return $this->shortUrlStringifier->stringify($shortUrl); + } +} diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php new file mode 100644 index 00000000..d2423684 --- /dev/null +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -0,0 +1,27 @@ +siteId === null) { + return null; + } + + // We enforce site ID to be hydrated as a numeric string or int, so it's safe to cast to int here + return (int) $this->siteId; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php new file mode 100644 index 00000000..655bbd0b --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -0,0 +1,39 @@ +options->siteId(); + if ($siteId === null || $this->options->baseUrl === null || $this->options->apiToken === null) { + throw new RuntimeException( + 'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined', + ); + } + + // Create a new MatomoTracker on every request, because it infers request info during construction + $tracker = new MatomoTracker($siteId, $this->options->baseUrl); + // Token required to set the IP and location + $tracker->setTokenAuth($this->options->apiToken); + // We don't want to bulk send, as every request to Shlink will create a new tracker + $tracker->disableBulkTracking(); + // Ensure params are not sent in the URL, for security reasons + $tracker->setRequestMethodNonBulk('POST'); + + return $tracker; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php new file mode 100644 index 00000000..7601f17a --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php @@ -0,0 +1,16 @@ +date; } + public function userAgent(): string + { + return $this->userAgent; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index dd5fff91..9e4b88df 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -75,6 +75,6 @@ class VisitsTracker implements VisitsTrackerInterface $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(UrlVisited::withOriginalIpAddress($visit->getId(), $visitor->remoteAddress)); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index b6f21495..21c3bf1d 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -159,7 +159,7 @@ class LocateVisitTest extends TestCase { $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress); + $event = new UrlVisited('123', $originalIpAddress); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); $this->em->expects($this->once())->method('flush'); From f88d57b2b646256f8185374379b84c437726cd36 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 15 Nov 2023 20:02:35 +0100 Subject: [PATCH 61/77] Do not dispatch async job for matomo if disabled --- module/Core/config/event_dispatcher.config.php | 2 ++ .../src/EventDispatcher/Helper/EnabledListenerChecker.php | 3 +++ module/Core/src/Matomo/MatomoOptions.php | 8 ++++---- .../EventDispatcher/Helper/EnabledListenerCheckerTest.php | 3 +++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 312e3917..1a81d8ed 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; +use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; @@ -188,6 +189,7 @@ return (static function (): array { MercureOptions::class, Options\WebhookOptions::class, GeoLite2Options::class, + MatomoOptions::class, ], ], diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php index 97c0ca5d..269aed76 100644 --- a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Helper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher; +use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; @@ -19,6 +20,7 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface private readonly MercureOptions $mercureOptions, private readonly WebhookOptions $webhookOptions, private readonly GeoLite2Options $geoLiteOptions, + private readonly MatomoOptions $matomoOptions, ) { } @@ -35,6 +37,7 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled, EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(), + EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled, EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(), EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(), default => false, // Any unknown async listener should not be enabled by default diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php index d2423684..23599321 100644 --- a/module/Core/src/Matomo/MatomoOptions.php +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -7,11 +7,11 @@ namespace Shlinkio\Shlink\Core\Matomo; class MatomoOptions { public function __construct( - public readonly bool $enabled, - public readonly ?string $baseUrl, + public readonly bool $enabled = false, + public readonly ?string $baseUrl = null, /** @var numeric-string|int|null */ - private readonly string|int|null $siteId, - public readonly ?string $apiToken, + private readonly string|int|null $siteId = null, + public readonly ?string $apiToken = null, ) { } diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index de5017bd..44ef500c 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis; use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb; +use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; @@ -149,6 +150,7 @@ class EnabledListenerCheckerTest extends TestCase bool $mercureEnabled = false, bool $webhooksEnabled = false, bool $geoLiteEnabled = false, + bool $matomoEnabled = false, ): EnabledListenerChecker { return new EnabledListenerChecker( new RabbitMqOptions(enabled: $rabbitMqEnabled), @@ -156,6 +158,7 @@ class EnabledListenerCheckerTest extends TestCase new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null), new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), + new MatomoOptions(enabled: $matomoEnabled), ); } } From a7ed14a1c9e8c98ed1409a4f3ea82e314438fcf9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 16 Nov 2023 09:24:52 +0100 Subject: [PATCH 62/77] Enhance EnableListenerCheckerTest with support for matomo listener --- .../Helper/EnabledListenerCheckerTest.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index 44ef500c..00f78fe4 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker; +use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; @@ -27,7 +28,7 @@ class EnabledListenerCheckerTest extends TestCase #[Test, DataProvider('provideListeners')] public function syncListenersAreRegisteredByDefault(string $listener): void { - self::assertTrue($this->checker()->shouldRegisterListener('', $listener, false)); + self::assertTrue($this->checker()->shouldRegisterListener(event: '', listener: $listener, isAsync: false)); } public static function provideListeners(): iterable @@ -39,6 +40,7 @@ class EnabledListenerCheckerTest extends TestCase [NotifyNewShortUrlToRedis::class], [NotifyVisitToMercure::class], [NotifyNewShortUrlToMercure::class], + [SendVisitToMatomo::class], [NotifyVisitToWebHooks::class], [UpdateGeoLiteDb::class], ]; @@ -114,6 +116,18 @@ class EnabledListenerCheckerTest extends TestCase UpdateGeoLiteDb::class => true, 'unknown' => false, ]]; + yield 'Matomo' => [self::checker(matomoEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + SendVisitToMatomo::class => true, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; yield 'All disabled' => [self::checker(), [ NotifyVisitToRabbitMq::class => false, NotifyNewShortUrlToRabbitMq::class => false, @@ -131,6 +145,7 @@ class EnabledListenerCheckerTest extends TestCase mercureEnabled: true, webhooksEnabled: true, geoLiteEnabled: true, + matomoEnabled: true, ), [ NotifyVisitToRabbitMq::class => true, NotifyNewShortUrlToRabbitMq::class => true, @@ -138,6 +153,7 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => true, NotifyVisitToMercure::class => true, NotifyNewShortUrlToMercure::class => true, + SendVisitToMatomo::class => true, NotifyVisitToWebHooks::class => true, UpdateGeoLiteDb::class => true, 'unknown' => false, From 5e6ebfa5a95f3ce4e595a18d7de3a88d93bddcc0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Nov 2023 09:32:07 +0100 Subject: [PATCH 63/77] Update shlink-event-dispatcher --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 51e46b75..8ccb6e14 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "ramsey/uuid": "^4.7", "shlinkio/shlink-common": "dev-main#7d46772 as 5.7", "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", - "shlinkio/shlink-event-dispatcher": "dev-main#faf2582 as 3.1", + "shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1", "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", "shlinkio/shlink-installer": "dev-develop#c505a19 as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", From e1f2dcc136e4469dc4f9c1dd984c69c6aad08833 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Nov 2023 23:31:23 +0100 Subject: [PATCH 64/77] Create MatomoTrackerBuilderTest --- .../test/Matomo/MatomoTrackerBuilderTest.php | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 module/Core/test/Matomo/MatomoTrackerBuilderTest.php diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php new file mode 100644 index 00000000..b7550bad --- /dev/null +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -0,0 +1,49 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined', + ); + $this->builder($options)->buildMatomoTracker(); + } + + public static function provideInvalidOptions(): iterable + { + yield [new MatomoOptions()]; + yield [new MatomoOptions(baseUrl: 'base_url')]; + yield [new MatomoOptions(apiToken: 'api_token')]; + yield [new MatomoOptions(siteId: 5)]; + yield [new MatomoOptions(baseUrl: 'base_url', apiToken: 'api_token')]; + yield [new MatomoOptions(baseUrl: 'base_url', siteId: 5)]; + yield [new MatomoOptions(siteId: 5, apiToken: 'api_token')]; + } + + #[Test] + public function trackerIsCreated(): void + { + $tracker = $this->builder()->buildMatomoTracker(); + + self::assertEquals('api_token', $tracker->token_auth); + self::assertEquals(5, $tracker->idSite); + } + + private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder + { + $options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token'); + return new MatomoTrackerBuilder($options); + } +} From bd5d3cb6fa3b41d9b28fbaac7949c3071a716216 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 20 Nov 2023 10:11:15 +0100 Subject: [PATCH 65/77] Create SendVisitToMatomoTest --- .../ShortUrl/Helper/ShortUrlStringifier.php | 3 + .../Matomo/SendVisitToMatomoTest.php | 189 ++++++++++++++++++ .../test/Matomo/MatomoTrackerBuilderTest.php | 4 +- 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 9d21cb58..886a4d25 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -11,6 +11,9 @@ use function sprintf; class ShortUrlStringifier implements ShortUrlStringifierInterface { + /** + * @param array{schema?: string, hostname?: string} $domainConfig + */ public function __construct(private readonly array $domainConfig, private readonly string $basePath = '') { } diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php new file mode 100644 index 00000000..154c7943 --- /dev/null +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -0,0 +1,189 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + } + + #[Test] + public function visitIsNotSentWhenMatomoIsDisabled(): void + { + $this->em->expects($this->never())->method('find'); + $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener(enabled: false))(new VisitLocated('123')); + } + + #[Test] + public function visitIsNotSentWhenItDoesNotExist(): void + { + $this->em->expects($this->once())->method('find')->willReturn(null); + $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->once())->method('warning')->with( + 'Tried to send visit with id "{visitId}" to matomo, but it does not exist.', + ['visitId' => '123'], + ); + + ($this->listener())(new VisitLocated('123')); + } + + #[Test, DataProvider('provideTrackerMethods')] + public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + { + $visitId = '123'; + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView')->with(''); + + if ($visit->isOrphan()) { + $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ + ['type', $visit->type()->value, $tracker], + ['orphan', 'true', $tracker], + ]); + } else { + $tracker->expects($this->once())->method('setCustomTrackingParameter')->with( + 'type', + $visit->type()->value, + )->willReturn($tracker); + } + + foreach ($invokedMethods as $invokedMethod) { + $tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker); + } + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener())(new VisitLocated($visitId, $originalIpAddress)); + } + + public static function provideTrackerMethods(): iterable + { + yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; + yield 'located regular visit' => [ + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) + ->locate(VisitLocation::fromGeolocation(new Location( + countryCode: 'countryCode', + countryName: 'countryName', + regionName: 'regionName', + city: 'city', + latitude: 123, + longitude: 123, + timeZone: 'timeZone', + ))), + '1.2.3.4', + ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], + ]; + yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; + } + + #[Test, DataProvider('provideUrlsToTrack')] + public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void + { + $visitId = '123'; + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView'); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener())(new VisitLocated($visitId)); + } + + public static function provideUrlsToTrack(): iterable + { + yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; + yield 'orphan visit with visited URL' => [ + Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), + 'https://s.test/foo', + ]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl(ShortUrl::create( + ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://shlink.io', + ShortUrlInputFilter::CUSTOM_SLUG => 'bar', + ]), + ), Visitor::emptyInstance()), + 'http://s2.test/bar', + ]; + } + + #[Test] + public function logsErrorWhenTrackingFails(): void + { + $visitId = '123'; + $e = new Exception('Error!'); + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->willThrowException($e); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( + $this->createMock(Visit::class), + ); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->once())->method('error')->with( + 'An error occurred while trying to send visit to Matomo. {e}', + ['e' => $e], + ); + + ($this->listener())(new VisitLocated($visitId)); + } + + private function listener(bool $enabled = true): SendVisitToMatomo + { + return new SendVisitToMatomo( + $this->em, + $this->logger, + new ShortUrlStringifier(['hostname' => 's2.test']), + new MatomoOptions(enabled: $enabled), + $this->trackerBuilder, + ); + } +} diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php index b7550bad..5a38412a 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -37,8 +37,8 @@ class MatomoTrackerBuilderTest extends TestCase { $tracker = $this->builder()->buildMatomoTracker(); - self::assertEquals('api_token', $tracker->token_auth); - self::assertEquals(5, $tracker->idSite); + self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line + self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line } private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder From c03eea789c76cf4d7c11b11137d1068cb386616b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Nov 2023 08:25:58 +0100 Subject: [PATCH 66/77] Fix LocateVisitTest --- module/Core/test/EventDispatcher/LocateVisitTest.php | 4 +++- .../test/EventDispatcher/Matomo/SendVisitToMatomoTest.php | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 21c3bf1d..ddadde84 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -168,7 +168,9 @@ class LocateVisitTest extends TestCase $location, ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); + $this->eventDispatcher->expects($this->once())->method('dispatch')->with( + new VisitLocated('123', $originalIpAddress), + ); $this->logger->expects($this->never())->method('warning'); ($this->locateVisit)($event); diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index 154c7943..b76a1d31 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -160,13 +160,10 @@ class SendVisitToMatomoTest extends TestCase $visitId = '123'; $e = new Exception('Error!'); - $tracker = $this->createMock(MatomoTracker::class); - $tracker->expects($this->once())->method('setUrl')->willThrowException($e); - $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( $this->createMock(Visit::class), ); - $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willThrowException($e); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->once())->method('error')->with( 'An error occurred while trying to send visit to Matomo. {e}', From 316b88cea6c08e89cc6471703a241212c8b94700 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Nov 2023 08:34:37 +0100 Subject: [PATCH 67/77] Add 10 second timeout to matomo requests --- .../Core/src/Matomo/MatomoTrackerBuilder.php | 18 ++++++++++++------ .../test/Matomo/MatomoTrackerBuilderTest.php | 2 ++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php index 655bbd0b..393e0472 100644 --- a/module/Core/src/Matomo/MatomoTrackerBuilder.php +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -9,6 +9,8 @@ use Shlinkio\Shlink\Core\Exception\RuntimeException; class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface { + public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds + public function __construct(private readonly MatomoOptions $options) { } @@ -27,12 +29,16 @@ class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface // Create a new MatomoTracker on every request, because it infers request info during construction $tracker = new MatomoTracker($siteId, $this->options->baseUrl); - // Token required to set the IP and location - $tracker->setTokenAuth($this->options->apiToken); - // We don't want to bulk send, as every request to Shlink will create a new tracker - $tracker->disableBulkTracking(); - // Ensure params are not sent in the URL, for security reasons - $tracker->setRequestMethodNonBulk('POST'); + $tracker + // Token required to set the IP and location + ->setTokenAuth($this->options->apiToken) + // Ensure params are not sent in the URL, for security reasons + ->setRequestMethodNonBulk('POST') + // Set a reasonable timeout + ->setRequestTimeout(self::MATOMO_DEFAULT_TIMEOUT) + ->setRequestConnectTimeout(self::MATOMO_DEFAULT_TIMEOUT) + // We don't want to bulk send, as every request to Shlink will create a new tracker + ->disableBulkTracking(); return $tracker; } diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php index 5a38412a..5a4e6ab0 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -39,6 +39,8 @@ class MatomoTrackerBuilderTest extends TestCase self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line + self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout()); + self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); } private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder From e783bdc456e8407f4d25c1f4330467d0666f3546 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Nov 2023 10:01:27 +0100 Subject: [PATCH 68/77] Set referrer when sending visits to Matomo --- .../src/EventDispatcher/Matomo/SendVisitToMatomo.php | 3 ++- module/Core/src/Matomo/MatomoTrackerBuilder.php | 9 ++++++--- module/Core/src/Visit/Entity/Visit.php | 5 +++++ .../EventDispatcher/Matomo/SendVisitToMatomoTest.php | 2 ++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index 4e0bcb86..ad9660cb 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -47,7 +47,8 @@ class SendVisitToMatomo $tracker ->setUrl($this->resolveUrlToTrack($visit)) ->setCustomTrackingParameter('type', $visit->type()->value) - ->setUserAgent($visit->userAgent()); + ->setUserAgent($visit->userAgent()) + ->setUrlReferrer($visit->referer()); $location = $visit->getVisitLocation(); if ($location !== null) { diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php index 393e0472..4bad6799 100644 --- a/module/Core/src/Matomo/MatomoTrackerBuilder.php +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -36,9 +36,12 @@ class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface ->setRequestMethodNonBulk('POST') // Set a reasonable timeout ->setRequestTimeout(self::MATOMO_DEFAULT_TIMEOUT) - ->setRequestConnectTimeout(self::MATOMO_DEFAULT_TIMEOUT) - // We don't want to bulk send, as every request to Shlink will create a new tracker - ->disableBulkTracking(); + ->setRequestConnectTimeout(self::MATOMO_DEFAULT_TIMEOUT); + + // We don't want to bulk send, as every request to Shlink will create a new tracker + $tracker->disableBulkTracking(); + // Disable cookies, as they are ignored anyway + $tracker->disableCookieSupport(); return $tracker; } diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index a735a3fb..255a55f4 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -193,6 +193,11 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->userAgent; } + public function referer(): string + { + return $this->referer; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index b76a1d31..94c66623 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -71,6 +71,7 @@ class SendVisitToMatomoTest extends TestCase $tracker = $this->createMock(MatomoTracker::class); $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); $tracker->expects($this->once())->method('doTrackPageView')->with(''); if ($visit->isOrphan()) { @@ -125,6 +126,7 @@ class SendVisitToMatomoTest extends TestCase $tracker = $this->createMock(MatomoTracker::class); $tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker); $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); $tracker->expects($this->once())->method('doTrackPageView'); From 5e6e386c5a877dac79563ec535bab001eacaba6c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Nov 2023 18:30:03 +0100 Subject: [PATCH 69/77] Add matomo dev config --- config/autoload/matomo.local.php.dist | 26 ++++++++++++++++++++++++++ docker-compose.yml | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 config/autoload/matomo.local.php.dist diff --git a/config/autoload/matomo.local.php.dist b/config/autoload/matomo.local.php.dist new file mode 100644 index 00000000..2a940407 --- /dev/null +++ b/config/autoload/matomo.local.php.dist @@ -0,0 +1,26 @@ + [ +// 'enabled' => true, +// 'base_url' => 'http://shlink_matomo', +// 'site_id' => '...', +// 'api_token' => '...', + ], + +]; diff --git a/docker-compose.yml b/docker-compose.yml index 5a0b6278..e44ca82b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -211,7 +211,7 @@ services: ports: - "8003:80" volumes: - # Matomo does not persist port in trusted hosts. This is needed to edit config afterward + # Matomo does not persist port in trusted hosts. This volume is needed to edit config afterward # https://github.com/matomo-org/matomo/issues/9549 - ./data/infra/matomo:/var/www/html links: From bd5d3f6897f90754a36e8d6d546dc4bdd0d990ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Nov 2023 18:51:47 +0100 Subject: [PATCH 70/77] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e667fd19..353a4abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance. + * [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role. Keys with this role will always get `0` when fetching orphan visits. From a3554eaf74097300699aa9d131ac231012d0c808 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 Nov 2023 09:31:02 +0100 Subject: [PATCH 71/77] Print a warning when manually running visit:download-db with no license --- CHANGELOG.md | 1 + .../Visit/DownloadGeoLiteDbCommand.php | 43 ++++++++++++------- .../CLI/src/GeoLite/GeolocationDbUpdater.php | 3 +- module/CLI/src/GeoLite/GeolocationResult.php | 1 + .../Visit/DownloadGeoLiteDbCommandTest.php | 15 +++++++ .../test/GeoLite/GeolocationDbUpdaterTest.php | 16 +++++++ 6 files changed, 61 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353a4abf..c91273e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1055](https://github.com/shlinkio/shlink/issues/1055) Update OAS definition to v3.1. * [#1885](https://github.com/shlinkio/shlink/issues/1885) Update to chronos 3.0. * [#1896](https://github.com/shlinkio/shlink/issues/1896) Requests to health endpoint are no longer logged. +* [#1877](https://github.com/shlinkio/shlink/issues/1877) Print a warning when manually running `visit:download-db` command and a GeoLite2 license was not provided. ### Deprecated * [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage. diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 23600530..8da6c753 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; use Shlinkio\Shlink\CLI\Util\ExitCode; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; @@ -41,7 +42,7 @@ class DownloadGeoLiteDbCommand extends Command $io = new SymfonyStyle($input, $output); try { - $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void { + $result = $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void { $io->text(sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading')); $this->progressBar = new ProgressBar($io); }, function (int $total, int $downloaded): void { @@ -49,6 +50,11 @@ class DownloadGeoLiteDbCommand extends Command $this->progressBar?->setProgress($downloaded); }); + if ($result === GeolocationResult::LICENSE_MISSING) { + $io->warning('It was not possible to download GeoLite2 db, because a license was not provided.'); + return ExitCode::EXIT_WARNING; + } + if ($this->progressBar === null) { $io->info('GeoLite2 db file is up to date.'); } else { @@ -58,21 +64,26 @@ class DownloadGeoLiteDbCommand extends Command return ExitCode::EXIT_SUCCESS; } catch (GeolocationDbUpdateFailedException $e) { - $olderDbExists = $e->olderDbExists(); - - if ($olderDbExists) { - $io->warning( - 'GeoLite2 db file update failed. Visits will continue to be located with the old version.', - ); - } else { - $io->error('GeoLite2 db file download failed. It will not be possible to locate visits.'); - } - - if ($io->isVerbose()) { - $this->getApplication()?->renderThrowable($e, $io); - } - - return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE; + return $this->processGeoLiteUpdateError($e, $io); } } + + private function processGeoLiteUpdateError(GeolocationDbUpdateFailedException $e, SymfonyStyle $io): int + { + $olderDbExists = $e->olderDbExists(); + + if ($olderDbExists) { + $io->warning( + 'GeoLite2 db file update failed. Visits will continue to be located with the old version.', + ); + } else { + $io->error('GeoLite2 db file download failed. It will not be possible to locate visits.'); + } + + if ($io->isVerbose()) { + $this->getApplication()?->renderThrowable($e, $io); + } + + return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE; + } } diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index e8f93b19..b377c14b 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -108,8 +108,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface $this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists)); return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED; } catch (MissingLicenseException) { - // If there's no license key, just ignore the error - return GeolocationResult::CHECK_SKIPPED; + return GeolocationResult::LICENSE_MISSING; } catch (DbUpdateException | WrongIpException $e) { throw $olderDbExists ? GeolocationDbUpdateFailedException::withOlderDb($e) diff --git a/module/CLI/src/GeoLite/GeolocationResult.php b/module/CLI/src/GeoLite/GeolocationResult.php index 7b245943..85976886 100644 --- a/module/CLI/src/GeoLite/GeolocationResult.php +++ b/module/CLI/src/GeoLite/GeolocationResult.php @@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\CLI\GeoLite; enum GeolocationResult { case CHECK_SKIPPED; + case LICENSE_MISSING; case DB_CREATED; case DB_UPDATED; case DB_IS_UP_TO_DATE; diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 78e14fa9..4d2754f8 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -72,6 +72,21 @@ class DownloadGeoLiteDbCommandTest extends TestCase ]; } + #[Test] + public function warningIsPrintedWhenLicenseIsMissing(): void + { + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn( + GeolocationResult::LICENSE_MISSING, + ); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + $exitCode = $this->commandTester->getStatusCode(); + + self::assertStringContainsString('[WARNING] It was not possible to download GeoLite2 db', $output); + self::assertSame(ExitCode::EXIT_WARNING, $exitCode); + } + #[Test, DataProvider('provideSuccessParams')] public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void { diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 2d47d79c..9d32ca79 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater; use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException; +use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Symfony\Component\Lock; use Throwable; @@ -37,6 +38,21 @@ class GeolocationDbUpdaterTest extends TestCase $this->lock->method('acquire')->with($this->isTrue())->willReturn(true); } + #[Test] + public function properResultIsReturnedWhenLicenseIsMissing(): void + { + $mustBeUpdated = fn () => self::assertTrue(true); + + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); + $this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException( + new MissingLicenseException(''), + ); + $this->geoLiteDbReader->expects($this->never())->method('metadata'); + + $result = $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); + self::assertEquals(GeolocationResult::LICENSE_MISSING, $result); + } + #[Test] public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void { From df57ca5edbb9492e4ff3a850f2e932f3102f45e1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 Nov 2023 11:22:23 +0100 Subject: [PATCH 72/77] Allow redis credentials be URL-decoded before passing them to connection --- CHANGELOG.md | 1 + composer.json | 6 +++--- config/autoload/cache.global.php | 1 + config/autoload/installer.global.php | 1 + module/Core/src/Config/EnvVars.php | 1 + module/Core/src/Model/DeviceType.php | 3 ++- 6 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c91273e5..40977d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is important if you are running multiple Shlink instance on the same server, or they share the same Redis instance (even more so if they are on different versions). * [#1905](https://github.com/shlinkio/shlink/issues/1905) Add support for PHP 8.3. +* [#1927](https://github.com/shlinkio/shlink/issues/1927) Allow redis credentials be URL-decoded before passing them to connection. ### Changed * [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled. diff --git a/composer.json b/composer.json index 8ccb6e14..c11ab753 100644 --- a/composer.json +++ b/composer.json @@ -41,16 +41,16 @@ "mezzio/mezzio-problem-details": "^1.13", "mezzio/mezzio-swoole": "^4.7", "mlocati/ip-lib": "^1.18", - "mobiledetect/mobiledetectlib": "^3.74", + "mobiledetect/mobiledetectlib": "^4.8", "pagerfanta/core": "^3.8", "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#7d46772 as 5.7", + "shlinkio/shlink-common": "dev-main#e24ea7b as 5.7", "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1", "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", - "shlinkio/shlink-installer": "dev-develop#c505a19 as 8.6", + "shlinkio/shlink-installer": "dev-develop#0fde6fd as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php index 94a9a183..30db2c0a 100644 --- a/config/autoload/cache.global.php +++ b/config/autoload/cache.global.php @@ -11,6 +11,7 @@ return (static function (): array { 'redis' => [ 'servers' => $redisServers, 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), + 'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false), ], ]; diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index e48b0ec7..77cb4439 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -32,6 +32,7 @@ return [ Option\Worker\TaskWorkerNumConfigOption::class, Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, + Option\Redis\RedisDecodeCredentialsConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class, Option\Redis\RedisPubSubConfigOption::class, Option\UrlShortener\ShortCodeLengthOption::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index c966043f..d6877eb9 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -20,6 +20,7 @@ enum EnvVars: string case CACHE_NAMESPACE = 'CACHE_NAMESPACE'; case REDIS_SERVERS = 'REDIS_SERVERS'; case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; + case REDIS_DECODE_CREDENTIALS = 'REDIS_DECODE_CREDENTIALS'; case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED'; case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php index 3b6d9fcc..3cd3e132 100644 --- a/module/Core/src/Model/DeviceType.php +++ b/module/Core/src/Model/DeviceType.php @@ -12,7 +12,8 @@ enum DeviceType: string public static function matchFromUserAgent(string $userAgent): ?self { - $detect = new MobileDetect(userAgent: $userAgent); + $detect = new MobileDetect(); + $detect->setUserAgent($userAgent); return match (true) { // $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only From aa01c034dba0854f1a5a1ee4df6c79c0892587ac Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Nov 2023 09:55:01 +0100 Subject: [PATCH 73/77] Update to installer with runtime question --- composer.json | 2 +- config/autoload/installer.global.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c11ab753..99641f50 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1", "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", - "shlinkio/shlink-installer": "dev-develop#0fde6fd as 8.6", + "shlinkio/shlink-installer": "dev-develop#f31c242 as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 77cb4439..4b31c46f 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -11,6 +11,7 @@ return [ 'installer' => [ 'enabled_options' => [ + Option\Server\RuntimeConfigOption::class, Option\Database\DatabaseDriverConfigOption::class, Option\Database\DatabaseNameConfigOption::class, Option\Database\DatabaseHostConfigOption::class, From 17e0c9176ea1b1f1b646a234b5072a3c28204c70 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Nov 2023 13:04:30 +0100 Subject: [PATCH 74/77] Add support for SSL on Redis and RabbitMQ connections --- composer.json | 4 ++-- config/autoload/installer.global.php | 1 + config/autoload/rabbit.global.php | 1 + module/Core/src/Config/EnvVars.php | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 99641f50..27f8b9ed 100644 --- a/composer.json +++ b/composer.json @@ -46,11 +46,11 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#e24ea7b as 5.7", + "shlinkio/shlink-common": "dev-main#a9b5d21 as 5.7", "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", "shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1", "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", - "shlinkio/shlink-installer": "dev-develop#f31c242 as 8.6", + "shlinkio/shlink-installer": "dev-develop#18829f7 as 8.6", "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 4b31c46f..32f71ea6 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -64,6 +64,7 @@ return [ Option\QrCode\DefaultRoundBlockSizeConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class, + Option\RabbitMq\RabbitMqUseSslConfigOption::class, Option\RabbitMq\RabbitMqPortConfigOption::class, Option\RabbitMq\RabbitMqUserConfigOption::class, Option\RabbitMq\RabbitMqPasswordConfigOption::class, diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index ea003809..bf9591e5 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -9,6 +9,7 @@ return [ 'rabbitmq' => [ 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false), 'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(), + 'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(false), 'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'), 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index d6877eb9..790bfe3a 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -31,6 +31,7 @@ enum EnvVars: string case RABBITMQ_USER = 'RABBITMQ_USER'; case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD'; case RABBITMQ_VHOST = 'RABBITMQ_VHOST'; + case RABBITMQ_USE_SSL = 'RABBITMQ_USE_SSL'; /** @deprecated */ case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; case MATOMO_ENABLED = 'MATOMO_ENABLED'; From 5d99b1aef02a68135eeb230f7c11d3fd262f9ddf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Nov 2023 13:07:25 +0100 Subject: [PATCH 75/77] Update changelog --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40977d18..bc34660c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance. - * [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role. Keys with this role will always get `0` when fetching orphan visits. @@ -20,6 +19,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1905](https://github.com/shlinkio/shlink/issues/1905) Add support for PHP 8.3. * [#1927](https://github.com/shlinkio/shlink/issues/1927) Allow redis credentials be URL-decoded before passing them to connection. +* [#1834](https://github.com/shlinkio/shlink/issues/1834) Add support for redis encrypted connections using SSL/TLS. + + Encryption should work out of the box if servers schema is set tp `tls` or `rediss`, including support for self-signed certificates. + +* [#1906](https://github.com/shlinkio/shlink/issues/1906) Add support for RabbitMQ encrypted connections using SSL/TLS. + + In order to enable SLL, you need to pass `RABBITMQ_USE_SSL=true` or the corresponding config option. + + Connections using self-signed certificates should work out of the box. ### Changed * [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled. From a931c60230743c6df53c2e07f5e9675c0191f927 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Nov 2023 20:08:29 +0100 Subject: [PATCH 76/77] Point to actual versions on shlink deps --- CHANGELOG.md | 6 +++++- README.md | 2 +- composer.json | 16 ++++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc34660c..2b0d0d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [3.7.0] - 2023-11-25 ### Added * [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance. * [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role. @@ -23,12 +23,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Encryption should work out of the box if servers schema is set tp `tls` or `rediss`, including support for self-signed certificates. + This has been tested with AWS ElasticCache using in-transit encryption, and with Digital Ocean Redis database. + * [#1906](https://github.com/shlinkio/shlink/issues/1906) Add support for RabbitMQ encrypted connections using SSL/TLS. In order to enable SLL, you need to pass `RABBITMQ_USE_SSL=true` or the corresponding config option. Connections using self-signed certificates should work out of the box. + This has been tested with AWS RabbitMQ using in-transit encryption, and with CloudAMQP. + ### Changed * [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled. diff --git a/README.md b/README.md index c01e343c..ee27f030 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,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.2 +* PHP 8.2 or 8.3 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * apcu extension is recommended if you don't plan to use openswoole. * xml extension is required if you want to generate QR codes in svg format. diff --git a/composer.json b/composer.json index 27f8b9ed..84dc7f82 100644 --- a/composer.json +++ b/composer.json @@ -46,13 +46,13 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#a9b5d21 as 5.7", - "shlinkio/shlink-config": "dev-main#cde5d3b as 2.5", - "shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1", - "shlinkio/shlink-importer": "dev-main#d621b20 as 5.2", - "shlinkio/shlink-installer": "dev-develop#18829f7 as 8.6", - "shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3", - "shlinkio/shlink-json": "dev-main#e5a111c as 1.1", + "shlinkio/shlink-common": "^5.7", + "shlinkio/shlink-config": "^2.5", + "shlinkio/shlink-event-dispatcher": "^3.1", + "shlinkio/shlink-importer": "^5.2", + "shlinkio/shlink-installer": "^8.6", + "shlinkio/shlink-ip-geolocation": "3.3", + "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.2", "spiral/roadrunner-cli": "^2.5", "spiral/roadrunner-http": "^3.1", @@ -76,7 +76,7 @@ "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "dev-main#cbbb64e as 3.8.0", + "shlinkio/shlink-test-utils": "^3.8", "symfony/var-dumper": "^6.3", "veewee/composer-run-parallel": "^1.3" }, From a91a560651cd714591d98ab038cb8c17ad2e7b44 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Nov 2023 20:12:41 +0100 Subject: [PATCH 77/77] Fix typo in version contraint --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 84dc7f82..0e1b996e 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "shlinkio/shlink-event-dispatcher": "^3.1", "shlinkio/shlink-importer": "^5.2", "shlinkio/shlink-installer": "^8.6", - "shlinkio/shlink-ip-geolocation": "3.3", + "shlinkio/shlink-ip-geolocation": "^3.3", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.2", "spiral/roadrunner-cli": "^2.5",