From f7ae52f86e1294fccdd7740a7046ef012b616c90 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Dec 2022 10:59:42 +0100 Subject: [PATCH 01/59] Fixed build badge in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c6dfa953..c1a7ae3a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/main/public/images/shlink-hero.png) -[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22) +[![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22) [![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink) [![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) From e71f6bb5280f7141cd2c3fc1f64ca2051a7cd209 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Dec 2022 16:35:20 +0100 Subject: [PATCH 02/59] Documented support for PHP 8.2 in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c1a7ae3a..e721d8a1 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.1 +* PHP 8.1 or 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. From 37c8328eedd54f0ce85ec8c167d3c052aa06730d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 12:28:34 +0100 Subject: [PATCH 03/59] Added split info about bots, non-bots and total visits to the visits stats --- .../Transformer/ShortUrlDataTransformer.php | 18 ++++-------- module/Core/src/Visit/Model/VisitsStats.php | 27 +++++++++++++++--- module/Core/src/Visit/Model/VisitsSummary.php | 28 +++++++++++++++++++ .../Adapter/OrphanVisitsPaginatorAdapter.php | 15 +++++----- .../Persistence/VisitsCountFiltering.php | 2 +- module/Core/src/Visit/VisitsStatsHelper.php | 12 +++++--- .../PublishingUpdatesGeneratorTest.php | 13 ++------- .../Core/test/Visit/VisitsStatsHelperTest.php | 12 ++++---- 8 files changed, 82 insertions(+), 45 deletions(-) create mode 100644 module/Core/src/Visit/Model/VisitsSummary.php diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index bd82cd9d..08327a98 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use function Functional\invoke; use function Functional\invoke_if; @@ -33,7 +34,10 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'title' => $shortUrl->title(), 'crawlable' => $shortUrl->crawlable(), 'forwardQuery' => $shortUrl->forwardQuery(), - 'visitsSummary' => $this->buildVisitsSummary($shortUrl), + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots( + $shortUrl->getVisitsCount(), + $shortUrl->nonBotVisitsCount(), + ), // Deprecated 'visitsCount' => $shortUrl->getVisitsCount(), @@ -52,16 +56,4 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'maxVisits' => $maxVisits, ]; } - - private function buildVisitsSummary(ShortUrl $shortUrl): array - { - $totalVisits = $shortUrl->getVisitsCount(); - $nonBotVisits = $shortUrl->nonBotVisitsCount(); - - return [ - 'total' => $totalVisits, - 'nonBots' => $nonBotVisits, - 'bots' => $totalVisits - $nonBotVisits, - ]; - } } diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index 475a25b5..adac34eb 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -8,15 +8,34 @@ use JsonSerializable; final class VisitsStats implements JsonSerializable { - public function __construct(private int $visitsCount, private int $orphanVisitsCount) - { + private readonly VisitsSummary $nonOrphanVisitsSummary; + private readonly VisitsSummary $orphanVisitsSummary; + + public function __construct( + int $nonOrphanVisitsTotal, + int $orphanVisitsTotal, + ?int $nonOrphanVisitsNonBots = null, + ?int $orphanVisitsNonBots = null, + ) { + $this->nonOrphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( + $nonOrphanVisitsTotal, + $nonOrphanVisitsNonBots ?? $nonOrphanVisitsTotal, + ); + $this->orphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( + $orphanVisitsTotal, + $orphanVisitsNonBots ?? $orphanVisitsTotal, + ); } public function jsonSerialize(): array { return [ - 'visitsCount' => $this->visitsCount, - 'orphanVisitsCount' => $this->orphanVisitsCount, + 'nonOrphanVisits' => $this->nonOrphanVisitsSummary, + 'orphanVisits' => $this->orphanVisitsSummary, + + // Deprecated + 'visitsCount' => $this->nonOrphanVisitsSummary->total, + 'orphanVisitsCount' => $this->orphanVisitsSummary->total, ]; } } diff --git a/module/Core/src/Visit/Model/VisitsSummary.php b/module/Core/src/Visit/Model/VisitsSummary.php new file mode 100644 index 00000000..654170cb --- /dev/null +++ b/module/Core/src/Visit/Model/VisitsSummary.php @@ -0,0 +1,28 @@ + $this->total, + 'nonBots' => $this->nonBots, + 'bots' => $this->total - $this->nonBots, + ]; + } +} diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index c181665e..4e6e4daf 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -12,26 +12,25 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params) + public function __construct(private readonly VisitRepositoryInterface $repo, private readonly VisitsParams $params) { } protected function doCount(): int { return $this->repo->countOrphanVisits(new VisitsCountFiltering( - $this->params->dateRange, - $this->params->excludeBots, + dateRange: $this->params->dateRange, + excludeBots: $this->params->excludeBots, )); } public function getSlice(int $offset, int $length): iterable { return $this->repo->findOrphanVisits(new VisitsListFiltering( - $this->params->dateRange, - $this->params->excludeBots, - null, - $length, - $offset, + dateRange: $this->params->dateRange, + excludeBots: $this->params->excludeBots, + limit: $length, + offset: $offset, )); } } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index f839a945..c445200e 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -18,6 +18,6 @@ class VisitsCountFiltering public static function withApiKey(?ApiKey $apiKey): self { - return new self(null, false, $apiKey); + return new self(apiKey: $apiKey); } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index dcba7030..2f28f0dd 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -32,7 +32,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface { - public function __construct(private EntityManagerInterface $em) + public function __construct(private readonly EntityManagerInterface $em) { } @@ -42,13 +42,17 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $visitsRepo = $this->em->getRepository(Visit::class); return new VisitsStats( - $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), - $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), + orphanVisitsTotal: $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( + new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), + ), + orphanVisitsNonBots: $visitsRepo->countOrphanVisits(new VisitsCountFiltering(excludeBots: true)), ); } /** - * @return Visit[]|Paginator + * @return Paginator * @throws ShortUrlNotFoundException */ public function visitsForShortUrl( diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index c7a4ecd0..cda8fe98 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; @@ -63,11 +64,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'title' => $title, 'crawlable' => false, 'forwardQuery' => true, - 'visitsSummary' => [ - 'total' => 0, - 'nonBots' => 0, - 'bots' => 0, - ], + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), ], 'visit' => [ 'referer' => '', @@ -144,11 +141,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'title' => $shortUrl->title(), 'crawlable' => false, 'forwardQuery' => true, - 'visitsSummary' => [ - 'total' => 0, - 'nonBots' => 0, - 'bots' => 0, - ], + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), ]], $update->payload); } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 8afd56db..1774ba6a 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -53,11 +53,13 @@ class VisitsStatsHelperTest extends TestCase public function returnsExpectedVisitsStats(int $expectedCount): void { $repo = $this->createMock(VisitRepository::class); - $repo->expects($this->once())->method('countNonOrphanVisits')->with(new VisitsCountFiltering())->willReturn( - $expectedCount * 3, - ); - $repo->expects($this->once())->method('countOrphanVisits')->with( - $this->isInstanceOf(VisitsCountFiltering::class), + $repo->expects($this->exactly(2))->method('countNonOrphanVisits')->withConsecutive( + [new VisitsCountFiltering()], + [new VisitsCountFiltering(excludeBots: true)], + )->willReturn($expectedCount * 3); + $repo->expects($this->exactly(2))->method('countOrphanVisits')->withConsecutive( + [$this->isInstanceOf(VisitsCountFiltering::class)], + [$this->isInstanceOf(VisitsCountFiltering::class)], )->willReturn($expectedCount); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); From d734578f74aaa4938876fc347017c3fab2390212 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 12:35:15 +0100 Subject: [PATCH 04/59] Reflected changes to visits stats in the swagger docs --- docs/swagger/definitions/ShortUrl.json | 2 +- docs/swagger/definitions/VisitStats.json | 14 +++++++++++--- ...ortUrlVisitsSummary.json => VisitsSummary.json} | 2 +- docs/swagger/paths/v2_visits.json | 12 ++++++++++-- 4 files changed, 23 insertions(+), 7 deletions(-) rename docs/swagger/definitions/{ShortUrlVisitsSummary.json => VisitsSummary.json} (83%) diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index ab66f506..4d5d9f2d 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -38,7 +38,7 @@ "description": "**[DEPRECATED]** Use `visitsSummary.total` instead." }, "visitsSummary": { - "$ref": "./ShortUrlVisitsSummary.json" + "$ref": "./VisitsSummary.json" }, "tags": { "type": "array", diff --git a/docs/swagger/definitions/VisitStats.json b/docs/swagger/definitions/VisitStats.json index 2a97f597..2ed24375 100644 --- a/docs/swagger/definitions/VisitStats.json +++ b/docs/swagger/definitions/VisitStats.json @@ -1,14 +1,22 @@ { "type": "object", - "required": ["visitsCount", "orphanVisitsCount"], + "required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"], "properties": { + "nonOrphanVisits": { + "$ref": "./VisitsSummary.json" + }, + "orphanVisits": { + "$ref": "./VisitsSummary.json" + }, "visitsCount": { + "deprecated": true, "type": "number", - "description": "The total amount of visits received on any short URL." + "description": "**[DEPRECATED]** Use nonOrphanVisits.total instead" }, "orphanVisitsCount": { + "deprecated": true, "type": "number", - "description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)." + "description": "**[DEPRECATED]** Use orphanVisits.total instead" } } } diff --git a/docs/swagger/definitions/ShortUrlVisitsSummary.json b/docs/swagger/definitions/VisitsSummary.json similarity index 83% rename from docs/swagger/definitions/ShortUrlVisitsSummary.json rename to docs/swagger/definitions/VisitsSummary.json index 404b7a75..c59b2ccd 100644 --- a/docs/swagger/definitions/ShortUrlVisitsSummary.json +++ b/docs/swagger/definitions/VisitsSummary.json @@ -3,7 +3,7 @@ "required": ["total", "nonBots", "bots"], "properties": { "total": { - "description": "The total amount of visits that this short URL has received.", + "description": "The total amount of visits.", "type": "integer" }, "nonBots": { diff --git a/docs/swagger/paths/v2_visits.json b/docs/swagger/paths/v2_visits.json index ded6ac6b..3db0ef67 100644 --- a/docs/swagger/paths/v2_visits.json +++ b/docs/swagger/paths/v2_visits.json @@ -31,8 +31,16 @@ }, "example": { "visits": { - "visitsCount": 1569874, - "orphanVisitsCount": 71345 + "nonOrphanVisits": { + "total": 64994, + "nonBots": 64986, + "bots": 8 + }, + "orphanVisits": { + "total": 37, + "nonBots": 34, + "bots": 3 + } } } } From 30e34151edbcd44b85d8adf289c469026a3d148a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 12:36:25 +0100 Subject: [PATCH 05/59] Updated changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b9b2aa..58de5219 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 +* [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint. + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.4.0] - 2022-12-16 ### Added * [#1612](https://github.com/shlinkio/shlink/issues/1612) Allowed to filter short URLs out of lists, when `validUntil` date is in the past or have reached their maximum amount of visits. From 8ecc241a4bedb0636cebe7d25d483f7d596dc15e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 12:45:08 +0100 Subject: [PATCH 06/59] Added API test for the visits stats endpoint --- .../Rest/test-api/Action/VisitStatsTest.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 module/Rest/test-api/Action/VisitStatsTest.php diff --git a/module/Rest/test-api/Action/VisitStatsTest.php b/module/Rest/test-api/Action/VisitStatsTest.php new file mode 100644 index 00000000..424eba73 --- /dev/null +++ b/module/Rest/test-api/Action/VisitStatsTest.php @@ -0,0 +1,68 @@ +callApiWithKey(self::METHOD_GET, '/visits', apiKey: $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(['visits' => $expectedPayload], $payload); + } + + public function provideApiKeysAndResults(): iterable + { + yield 'valid API key' => ['valid_api_key', [ + 'nonOrphanVisits' => [ + 'total' => 7, + 'nonBots' => 6, + 'bots' => 1, + ], + 'orphanVisits' => [ + 'total' => 3, + 'nonBots' => 2, + 'bots' => 1, + ], + 'visitsCount' => 7, + 'orphanVisitsCount' => 3, + ]]; + yield 'domain-only API key' => ['domain_api_key', [ + 'nonOrphanVisits' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], + 'orphanVisits' => [ + 'total' => 3, + 'nonBots' => 2, + 'bots' => 1, + ], + 'visitsCount' => 0, + 'orphanVisitsCount' => 3, + ]]; + yield 'author API key' => ['author_api_key', [ + 'nonOrphanVisits' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], + 'orphanVisits' => [ + 'total' => 3, + 'nonBots' => 2, + 'bots' => 1, + ], + 'visitsCount' => 5, + 'orphanVisitsCount' => 3, + ]]; + } +} From e0a9f8120c56d07b5dbecf558e70ebf5a8741461 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 12:48:23 +0100 Subject: [PATCH 07/59] Fixed unintended change in phpdoc --- module/Core/src/Visit/VisitsStatsHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 2f28f0dd..25f44921 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -52,7 +52,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface } /** - * @return Paginator + * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ public function visitsForShortUrl( From 812c5f4993746a14a1c1247bf2d7d1864d1ba1ae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 13:33:24 +0100 Subject: [PATCH 08/59] Added new handled error for when request body is not valid JSON --- .../src/Exception/MalformedBodyException.php | 29 +++++++++++++++ .../Exception/MalformedBodyExceptionTest.php | 27 ++++++++++++++ .../src/Middleware/BodyParserMiddleware.php | 10 ++++-- .../Middleware/BodyParserMiddlewareTest.php | 36 +++++++++++++------ 4 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 module/Core/src/Exception/MalformedBodyException.php create mode 100644 module/Core/test/Exception/MalformedBodyExceptionTest.php diff --git a/module/Core/src/Exception/MalformedBodyException.php b/module/Core/src/Exception/MalformedBodyException.php new file mode 100644 index 00000000..941730d1 --- /dev/null +++ b/module/Core/src/Exception/MalformedBodyException.php @@ -0,0 +1,29 @@ +detail = $e->getMessage(); + $e->title = 'Malformed request body'; + $e->type = toProblemDetailsType('malformed-request-body'); + $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + + return $e; + } +} diff --git a/module/Core/test/Exception/MalformedBodyExceptionTest.php b/module/Core/test/Exception/MalformedBodyExceptionTest.php new file mode 100644 index 00000000..ecccfdf2 --- /dev/null +++ b/module/Core/test/Exception/MalformedBodyExceptionTest.php @@ -0,0 +1,27 @@ +getPrevious()); + self::assertEquals('Provided request does not contain a valid JSON body.', $e->getMessage()); + self::assertEquals('Provided request does not contain a valid JSON body.', $e->getDetail()); + self::assertEquals('Malformed request body', $e->getTitle()); + self::assertEquals('https://shlink.io/api/error/malformed-request-body', $e->getType()); + self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); + } +} diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php index 8922de03..68fc1b38 100644 --- a/module/Rest/src/Middleware/BodyParserMiddleware.php +++ b/module/Rest/src/Middleware/BodyParserMiddleware.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Middleware; use Fig\Http\Message\RequestMethodInterface; +use JsonException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Exception\MalformedBodyException; use function Functional\contains; use function Shlinkio\Shlink\Common\json_decode; @@ -42,7 +44,11 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac return $request; } - $parsedJson = json_decode($rawBody); - return $request->withParsedBody($parsedJson); + try { + $parsedJson = json_decode($rawBody); + return $request->withParsedBody($parsedJson); + } catch (JsonException $e) { + throw MalformedBodyException::forInvalidJson($e); + } } } diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php index 63354a76..429a35ea 100644 --- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -7,10 +7,12 @@ namespace ShlinkioTest\Shlink\Rest\Middleware; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Exception\MalformedBodyException; use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware; class BodyParserMiddlewareTest extends TestCase @@ -65,7 +67,6 @@ class BodyParserMiddlewareTest extends TestCase /** @test */ public function jsonRequestsAreJsonDecoded(): void { - $test = $this; $body = new Stream('php://temp', 'wr'); $body->write('{"foo": "bar", "bar": ["one", 5]}'); $request = (new ServerRequest())->withMethod('PUT') @@ -73,16 +74,31 @@ class BodyParserMiddlewareTest extends TestCase $handler = $this->createMock(RequestHandlerInterface::class); $handler->expects($this->once())->method('handle')->with( $this->isInstanceOf(ServerRequestInterface::class), - )->willReturnCallback( - function (ServerRequestInterface $req) use ($test) { - $test->assertEquals([ - 'foo' => 'bar', - 'bar' => ['one', 5], - ], $req->getParsedBody()); + )->willReturnCallback(function (ServerRequestInterface $req) { + Assert::assertEquals([ + 'foo' => 'bar', + 'bar' => ['one', 5], + ], $req->getParsedBody()); - return new Response(); - }, - ); + return new Response(); + }); + + $this->middleware->process($request, $handler); + } + + /** @test */ + public function invalidBodyResultsInException(): void + { + $body = new Stream('php://temp', 'wr'); + $body->write('{"foo": "bar", "bar": ["one'); + $request = (new ServerRequest())->withMethod('PUT') + ->withBody($body); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $this->expectException(MalformedBodyException::class); + $this->expectExceptionMessage('Provided request does not contain a valid JSON body.'); $this->middleware->process($request, $handler); } From 112cbb9039b1facd29251158bb266477d332df6a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 13:38:04 +0100 Subject: [PATCH 09/59] Added API test for malformed request JSON body --- .../test-api/Middleware/BodyParserTest.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 module/Rest/test-api/Middleware/BodyParserTest.php diff --git a/module/Rest/test-api/Middleware/BodyParserTest.php b/module/Rest/test-api/Middleware/BodyParserTest.php new file mode 100644 index 00000000..e2170c76 --- /dev/null +++ b/module/Rest/test-api/Middleware/BodyParserTest.php @@ -0,0 +1,27 @@ +callApiWithKey(self::METHOD_POST, '/short-urls', [ + RequestOptions::HEADERS => ['content-type' => 'application/json'], + RequestOptions::BODY => '{"foo', + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(400, $resp->getStatusCode()); + self::assertEquals(400, $payload['status']); + self::assertEquals('Provided request does not contain a valid JSON body.', $payload['detail']); + self::assertEquals('Malformed request body', $payload['title']); + self::assertEquals('https://shlink.io/api/error/malformed-request-body', $payload['type']); + } +} From 6d5bce00782cd5aa13fabf2d86e81284353b74d9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 13:39:13 +0100 Subject: [PATCH 10/59] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58de5219..4c1dc3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* *Nothing* +* [#1639](https://github.com/shlinkio/shlink/issues/1639) Fixed 500 error returned when request body is not valid JSON, instead of a proper descriptive error. ## [3.4.0] - 2022-12-16 From 92c80e7833596ba22c67d7ac69facec4b4eb4bcb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 13:47:16 +0100 Subject: [PATCH 11/59] Removed superfluous exception code by using named args --- module/CLI/src/Exception/GeolocationDbUpdateFailedException.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index cbb8affd..ceb5cbfd 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -15,7 +15,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc private function __construct(string $message, ?Throwable $previous = null) { - parent::__construct($message, 0, $previous); + parent::__construct($message, previous: $previous); } public static function withOlderDb(?Throwable $prev = null): self From 961178fd82d3c91a06f91bafd8bf998b5e2fd0fd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 19:28:32 +0100 Subject: [PATCH 12/59] Added amount of bots, non-bots and total visits to the list of tags with stats --- .../CLI/src/Command/Tag/ListTagsCommand.php | 2 +- module/Core/functions/functions.php | 12 +++ module/Core/src/Tag/Model/OrderableField.php | 30 +++++++ module/Core/src/Tag/Model/TagInfo.php | 19 ++++- .../Core/src/Tag/Repository/TagRepository.php | 19 +++-- module/Core/src/Tag/TagService.php | 2 +- .../Tag/Repository/TagRepositoryTest.php | 83 ++++++++++--------- .../Rest/src/Action/Tag/TagsStatsAction.php | 2 +- module/Rest/test-api/Action/TagsStatsTest.php | 45 ++++++++++ 9 files changed, 159 insertions(+), 55 deletions(-) create mode 100644 module/Core/src/Tag/Model/OrderableField.php diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index cd820169..02116d79 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -46,7 +46,7 @@ class ListTagsCommand extends Command return map( $tags, - static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount], + static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total], ); } } diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index e7dff2ad..9d0b8d68 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -10,6 +10,7 @@ use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; use Laminas\Filter\Word\CamelCaseToSeparator; +use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; @@ -21,6 +22,7 @@ use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; +use function strtolower; use function ucfirst; function generateRandomShortCode(int $length): string @@ -143,6 +145,16 @@ function camelCaseToHumanFriendly(string $value): string return ucfirst($filter->filter($value)); } +function camelCaseToSnakeCase(string $value): string +{ + static $filter; + if ($filter === null) { + $filter = new CamelCaseToUnderscore(); + } + + return strtolower($filter->filter($value)); +} + function toProblemDetailsType(string $errorCode): string { return sprintf('https://shlink.io/api/error/%s', $errorCode); diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php new file mode 100644 index 00000000..cb398ebb --- /dev/null +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -0,0 +1,30 @@ +value || $field === self::VISITS_COUNT->value; + } + + public static function toSnakeCaseValidField(?string $field): string + { + return camelCaseToSnakeCase($field === self::SHORT_URLS_COUNT->value ? $field : self::VISITS_COUNT->value); + } +} diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 5e71ea5b..adf5d4b1 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -5,19 +5,29 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Model; use JsonSerializable; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; final class TagInfo implements JsonSerializable { + public readonly VisitsSummary $visitsSummary; + public function __construct( public readonly string $tag, public readonly int $shortUrlsCount, - public readonly int $visitsCount, + int $visitsCount, + ?int $nonBotVisitsCount = null, ) { + $this->visitsSummary = VisitsSummary::fromTotalAndNonBots($visitsCount, $nonBotVisitsCount ?? $visitsCount); } public static function fromRawData(array $data): self { - return new self($data['tag'], (int) $data['shortUrlsCount'], (int) $data['visitsCount']); + return new self( + $data['tag'], + (int) $data['shortUrlsCount'], + (int) $data['visitsCount'], + isset($data['nonBotVisitsCount']) ? (int) $data['nonBotVisitsCount'] : null, + ); } public function jsonSerialize(): array @@ -25,7 +35,10 @@ final class TagInfo implements JsonSerializable return [ 'tag' => $this->tag, 'shortUrlsCount' => $this->shortUrlsCount, - 'visitsCount' => $this->visitsCount, + 'visitsSummary' => $this->visitsSummary, + + // Deprecated + 'visitsCount' => $this->visitsSummary->total, ]; } } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 88e817ad..feaaa7d5 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Query\ResultSetMappingBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Tag\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\OrderableField; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; @@ -16,7 +17,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithInlinedApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function Functional\contains; use function Functional\map; use const PHP_INT_MAX; @@ -43,7 +43,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito { $orderField = $filtering?->orderBy?->field; $orderDir = $filtering?->orderBy?->direction; - $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); + $orderMainQuery = $orderField !== null && OrderableField::isAggregateField($orderField); $conn = $this->getEntityManager()->getConnection(); $subQb = $this->createQueryBuilder('t'); @@ -72,12 +72,17 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 't.id_0 AS id', 't.name_1 AS name', 'COUNT(DISTINCT s.id) AS short_urls_count', - 'COUNT(DISTINCT v.id) AS visits_count', + 'COUNT(DISTINCT v.id) AS visits_count', // Native queries require snake_case for cross-db compatibility + 'COUNT(DISTINCT v2.id) AS non_bot_visits_count', ) ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) - ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id')) + ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('st.short_url_id', 'v.short_url_id')) + ->leftJoin('st', 'visits', 'v2', $nativeQb->expr()->and( // @phpstan-ignore-line + $nativeQb->expr()->eq('st.short_url_id', 'v2.short_url_id'), + $nativeQb->expr()->eq('v2.potential_bot', $conn->quote('0')), + )) ->groupBy('t.id_0', 't.name_1'); // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates @@ -92,10 +97,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito if ($orderMainQuery) { $nativeQb - ->orderBy( - $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', - $orderDir ?? 'ASC', - ) + ->orderBy(OrderableField::toSnakeCaseValidField($orderField), $orderDir ?? 'ASC') ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) ->setFirstResult($filtering?->offset ?? 0); } @@ -107,6 +109,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $rsm->addScalarResult('name', 'tag'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); $rsm->addScalarResult('visits_count', 'visitsCount'); + $rsm->addScalarResult('non_bot_visits_count', 'nonBotVisitsCount'); return map( $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 66e031d3..d50ced75 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -22,7 +22,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagService implements TagServiceInterface { - public function __construct(private ORM\EntityManagerInterface $em) + public function __construct(private readonly ORM\EntityManagerInterface $em) { } diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index ce0efff9..fe030ca0 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -73,7 +73,7 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags] = array_chunk($names, 3); $secondUrlTags = [$names[0]]; - $metaWithTags = fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( + $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( ['longUrl' => '', 'tags' => $tags, 'apiKey' => $apiKey], ); @@ -81,7 +81,7 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::botInstance())); $shortUrl2 = ShortUrl::create($metaWithTags($secondUrlTags, null), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); @@ -100,9 +100,10 @@ class TagRepositoryTest extends DatabaseTestCase $result = $this->repo->findTagsWithInfo($filtering); self::assertCount(count($expectedList), $result); - foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount]) { + foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount, $nonBotVisitsCount]) { self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount); - self::assertEquals($visitsCount, $result[$index]->visitsCount); + self::assertEquals($visitsCount, $result[$index]->visitsSummary->total); + self::assertEquals($nonBotVisitsCount, $result[$index]->visitsSummary->nonBots); self::assertEquals($tag, $result[$index]->tag); } } @@ -110,95 +111,95 @@ class TagRepositoryTest extends DatabaseTestCase public function provideFilters(): iterable { $defaultList = [ - ['another', 0, 0], - ['bar', 3, 3], - ['baz', 1, 3], - ['foo', 2, 4], + ['another', 0, 0, 0], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], ]; yield 'no filter' => [null, $defaultList]; yield 'empty filter' => [new TagsListFiltering(), $defaultList]; yield 'limit' => [new TagsListFiltering(2), [ - ['another', 0, 0], - ['bar', 3, 3], + ['another', 0, 0, 0], + ['bar', 3, 3, 2], ]]; yield 'offset' => [new TagsListFiltering(null, 3), [ - ['foo', 2, 4], + ['foo', 2, 4, 3], ]]; yield 'limit and offset' => [new TagsListFiltering(2, 1), [ - ['bar', 3, 3], - ['baz', 1, 3], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], ]]; yield 'search term' => [new TagsListFiltering(null, null, 'ba'), [ - ['bar', 3, 3], - ['baz', 1, 3], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], ]]; yield 'ASC ordering' => [ new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'ASC'])), $defaultList, ]; yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])), [ - ['foo', 2, 4], - ['baz', 1, 3], - ['bar', 3, 3], - ['another', 0, 0], + ['foo', 2, 4, 3], + ['baz', 1, 3, 2], + ['bar', 3, 3, 2], + ['another', 0, 0, 0], ]]; yield 'short URLs count ASC ordering' => [ new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'ASC'])), [ - ['another', 0, 0], - ['baz', 1, 3], - ['foo', 2, 4], - ['bar', 3, 3], + ['another', 0, 0, 0], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], + ['bar', 3, 3, 2], ], ]; yield 'short URLs count DESC ordering' => [ new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'DESC'])), [ - ['bar', 3, 3], - ['foo', 2, 4], - ['baz', 1, 3], - ['another', 0, 0], + ['bar', 3, 3, 2], + ['foo', 2, 4, 3], + ['baz', 1, 3, 2], + ['another', 0, 0, 0], ], ]; yield 'visits count ASC ordering' => [ new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'ASC'])), [ - ['another', 0, 0], - ['bar', 3, 3], - ['baz', 1, 3], - ['foo', 2, 4], + ['another', 0, 0, 0], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], ], ]; yield 'visits count DESC ordering' => [ new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), [ - ['foo', 2, 4], - ['bar', 3, 3], - ['baz', 1, 3], - ['another', 0, 0], + ['foo', 2, 4, 3], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['another', 0, 0, 0], ], ]; yield 'visits count DESC ordering and limit' => [ new TagsListFiltering(2, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), [ - ['foo', 2, 4], - ['bar', 3, 3], + ['foo', 2, 4, 3], + ['bar', 3, 3, 2], ], ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ - ['bar', 2, 3], - ['baz', 1, 3], - ['foo', 1, 3], + ['bar', 2, 3, 2], + ['baz', 1, 3, 2], + ['foo', 1, 3, 2], ]]; yield 'combined' => [new TagsListFiltering(1, null, null, Ordering::fromTuple( ['shortUrls', 'DESC'], ), ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ - ['foo', 1, 3], + ['foo', 1, 3, 2], ]]; } diff --git a/module/Rest/src/Action/Tag/TagsStatsAction.php b/module/Rest/src/Action/Tag/TagsStatsAction.php index cec8edd6..6db3c62a 100644 --- a/module/Rest/src/Action/Tag/TagsStatsAction.php +++ b/module/Rest/src/Action/Tag/TagsStatsAction.php @@ -20,7 +20,7 @@ class TagsStatsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags/stats'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private TagServiceInterface $tagService) + public function __construct(private readonly TagServiceInterface $tagService) { } diff --git a/module/Rest/test-api/Action/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php index 3b91cbf0..573f1c38 100644 --- a/module/Rest/test-api/Action/TagsStatsTest.php +++ b/module/Rest/test-api/Action/TagsStatsTest.php @@ -52,16 +52,31 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'bar', 'shortUrlsCount' => 1, 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], ], [ 'tag' => 'baz', 'shortUrlsCount' => 0, 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], [ 'tag' => 'foo', 'shortUrlsCount' => 3, 'visitsCount' => 5, + 'visitsSummary' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], ], ], [ 'currentPage' => 1, @@ -75,11 +90,21 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'bar', 'shortUrlsCount' => 1, 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], ], [ 'tag' => 'baz', 'shortUrlsCount' => 0, 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], ], [ 'currentPage' => 1, @@ -93,11 +118,21 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'bar', 'shortUrlsCount' => 1, 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], ], [ 'tag' => 'foo', 'shortUrlsCount' => 2, 'visitsCount' => 5, + 'visitsSummary' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], ], ], [ 'currentPage' => 1, @@ -111,6 +146,11 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'foo', 'shortUrlsCount' => 2, 'visitsCount' => 5, + 'visitsSummary' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], ], ], [ 'currentPage' => 2, @@ -124,6 +164,11 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'foo', 'shortUrlsCount' => 1, 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], ], [ 'currentPage' => 1, From ce9ec0d7386d55f48365fb000f2a2202cc0d0a98 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 19:49:54 +0100 Subject: [PATCH 13/59] Fixed ordering in tags supporting more fields --- module/Core/src/Tag/Model/OrderableField.php | 20 ++++++----- module/Core/src/Tag/Model/TagInfo.php | 4 +-- .../Core/src/Tag/Repository/TagRepository.php | 8 ++--- .../Tag/Repository/TagRepositoryTest.php | 36 ++++++++++++++----- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index cb398ebb..eb802a7f 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -9,22 +9,26 @@ use function Shlinkio\Shlink\Core\camelCaseToSnakeCase; enum OrderableField: string { case TAG = 'tag'; -// case SHORT_URLS = 'shortUrls'; -// case VISITS = 'visits'; -// case NON_BOT_VISITS = 'nonBotVisits'; - + case SHORT_URLS_COUNT = 'shortUrlsCount'; + case VISITS = 'visits'; + case NON_BOT_VISITS = 'nonBotVisits'; /** @deprecated Use VISITS instead */ case VISITS_COUNT = 'visitsCount'; - /** @deprecated Use SHORT_URLS instead */ - case SHORT_URLS_COUNT = 'shortUrlsCount'; public static function isAggregateField(string $field): bool { - return $field === self::SHORT_URLS_COUNT->value || $field === self::VISITS_COUNT->value; + $parsed = self::tryFrom($field); + return $parsed !== null && $parsed !== self::TAG; } public static function toSnakeCaseValidField(?string $field): string { - return camelCaseToSnakeCase($field === self::SHORT_URLS_COUNT->value ? $field : self::VISITS_COUNT->value); + $parsed = self::tryFrom($field); + $normalized = match ($parsed) { + self::VISITS_COUNT, null => self::VISITS, + default => $parsed, + }; + + return camelCaseToSnakeCase($normalized->value); } } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index adf5d4b1..4c0018b2 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -25,8 +25,8 @@ final class TagInfo implements JsonSerializable return new self( $data['tag'], (int) $data['shortUrlsCount'], - (int) $data['visitsCount'], - isset($data['nonBotVisitsCount']) ? (int) $data['nonBotVisitsCount'] : null, + (int) $data['visits'], + isset($data['nonBotVisits']) ? (int) $data['nonBotVisits'] : null, ); } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index feaaa7d5..5dd9dcd9 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -72,8 +72,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 't.id_0 AS id', 't.name_1 AS name', 'COUNT(DISTINCT s.id) AS short_urls_count', - 'COUNT(DISTINCT v.id) AS visits_count', // Native queries require snake_case for cross-db compatibility - 'COUNT(DISTINCT v2.id) AS non_bot_visits_count', + 'COUNT(DISTINCT v.id) AS visits', // Native queries require snake_case for cross-db compatibility + 'COUNT(DISTINCT v2.id) AS non_bot_visits', ) ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) @@ -108,8 +108,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('name', 'tag'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); - $rsm->addScalarResult('visits_count', 'visitsCount'); - $rsm->addScalarResult('non_bot_visits_count', 'nonBotVisitsCount'); + $rsm->addScalarResult('visits', 'visits'); + $rsm->addScalarResult('non_bot_visits', 'nonBotVisits'); return map( $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index fe030ca0..57b3a795 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Tag\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\OrderableField; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -135,17 +136,21 @@ class TagRepositoryTest extends DatabaseTestCase ['baz', 1, 3, 2], ]]; yield 'ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::TAG->value, 'ASC'])), $defaultList, ]; - yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])), [ + yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::TAG->value, 'DESC'], + )), [ ['foo', 2, 4, 3], ['baz', 1, 3, 2], ['bar', 3, 3, 2], ['another', 0, 0, 0], ]]; yield 'short URLs count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::SHORT_URLS_COUNT->value, 'ASC'], + )), [ ['another', 0, 0, 0], ['baz', 1, 3, 2], @@ -154,7 +159,9 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'short URLs count DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'DESC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::SHORT_URLS_COUNT->value, 'DESC'], + )), [ ['bar', 3, 3, 2], ['foo', 2, 4, 3], @@ -163,7 +170,18 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'visits count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'ASC'])), + [ + ['another', 0, 0, 0], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], + ], + ]; + yield 'non-bot visits count ASC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::NON_BOT_VISITS->value, 'ASC'], + )), [ ['another', 0, 0, 0], ['bar', 3, 3, 2], @@ -172,7 +190,7 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'visits count DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'DESC'])), [ ['foo', 2, 4, 3], ['bar', 3, 3, 2], @@ -181,7 +199,7 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'visits count DESC ordering and limit' => [ - new TagsListFiltering(2, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), + new TagsListFiltering(2, null, null, Ordering::fromTuple([OrderableField::VISITS_COUNT->value, 'DESC'])), [ ['foo', 2, 4, 3], ['bar', 3, 3, 2], @@ -195,11 +213,11 @@ class TagRepositoryTest extends DatabaseTestCase ['foo', 1, 3, 2], ]]; yield 'combined' => [new TagsListFiltering(1, null, null, Ordering::fromTuple( - ['shortUrls', 'DESC'], + [OrderableField::SHORT_URLS_COUNT->value, 'DESC'], ), ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ - ['foo', 1, 3, 2], + ['bar', 2, 3, 2], ]]; } From a5929ebb297c9fbe96387fc5cb1c8c6fa81de133 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 19:58:02 +0100 Subject: [PATCH 14/59] Added swagger docs for visits summary in tags with stats --- docs/swagger/definitions/TagInfo.json | 9 +++++++-- docs/swagger/paths/v2_tags_stats.json | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/swagger/definitions/TagInfo.json b/docs/swagger/definitions/TagInfo.json index e881ce02..41de1068 100644 --- a/docs/swagger/definitions/TagInfo.json +++ b/docs/swagger/definitions/TagInfo.json @@ -1,5 +1,6 @@ { "type": "object", + "required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"], "properties": { "tag": { "type": "string", @@ -9,9 +10,13 @@ "type": "number", "description": "The amount of short URLs using this tag" }, - "userAgent": { + "visitsSummary": { + "$ref": "./VisitsSummary.json" + }, + "visitsCount": { + "deprecated": true, "type": "number", - "description": "The combined amount of visits received by short URLs with this tag" + "description": "**[DEPRECATED]** Use visitsSummary.total instead" } } } diff --git a/docs/swagger/paths/v2_tags_stats.json b/docs/swagger/paths/v2_tags_stats.json index 91771335..150cf7b3 100644 --- a/docs/swagger/paths/v2_tags_stats.json +++ b/docs/swagger/paths/v2_tags_stats.json @@ -45,7 +45,7 @@ { "name": "orderBy", "in": "query", - "description": "To determine how to order the results.

**Important!** Ordering by `shortUrlsCount` or `visitsCount` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.
If you plan to order by any of these fields, it's worth loading the whole list with no pagination.", + "description": "To determine how to order the results.

**Important!** Ordering by `shortUrlsCount`, `visits` or `nonBotVisits` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.
If you plan to order by any of these fields, it's worth loading the whole list with no pagination.", "required": false, "schema": { "type": "string", @@ -54,8 +54,10 @@ "tag-DESC", "shortUrlsCount-ASC", "shortUrlsCount-DESC", - "visitsCount-ASC", - "visitsCount-DESC" + "visits-ASC", + "visits-DESC", + "nonBotVisits-ASC", + "nonBotVisits-DESC" ] } } @@ -73,7 +75,6 @@ "required": ["data"], "properties": { "data": { - "description": "The tag stats will be returned only if the withStats param was provided with value 'true'", "type": "array", "items": { "$ref": "../definitions/TagInfo.json" @@ -92,12 +93,20 @@ { "tag": "games", "shortUrlsCount": 10, - "visitsCount": 521 + "visitsSummary": { + "total": 521, + "nonBots": 521, + "bots": 0 + } }, { "tag": "shlink", "shortUrlsCount": 7, - "visitsCount": 1087 + "visitsSummary": { + "total": 1087, + "nonBots": 1000, + "bots": 87 + } } ], "pagination": { From 0b96a79c410cce8cf755b0d1ab3316ba961e8a85 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 20:02:50 +0100 Subject: [PATCH 15/59] Updated async API docs --- docs/async-api/async-api.json | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 3b59e8e5..6ce83784 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -116,7 +116,11 @@ "format": "date-time", "description": "The date in which the short URL was created in ISO format." }, + "visitsSummary": { + "$ref": "#/components/schemas/VisitsSummary" + }, "visitsCount": { + "deprecated": true, "type": "integer", "description": "The number of visits that this short URL has received." }, @@ -149,7 +153,11 @@ "shortUrl": "https://doma.in/12C18", "longUrl": "https://store.steampowered.com", "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 328, + "visitsSummary": { + "total": 328, + "nonBots": 285, + "bots": 43 + }, "tags": [ "games", "tech" @@ -189,6 +197,24 @@ } } }, + "VisitsSummary": { + "type": "object", + "required": ["total", "nonBots", "bots"], + "properties": { + "total": { + "description": "The total amount of visits", + "type": "number" + }, + "nonBots": { + "description": "The amount of visits which were not identified as bots", + "type": "number" + }, + "bots": { + "description": "The amount of visits that were identified as potential bots", + "type": "number" + } + } + }, "Visit": { "type": "object", "properties": { From fc0aba6311a01c3029e7eb013a1978d6ecf79427 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 20:03:30 +0100 Subject: [PATCH 16/59] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1dc3dc..ba9b5cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint. +* [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint. ### Changed * *Nothing* From 46b4a216177cc755ca98ecd542e1e13b0108f389 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Jan 2023 20:17:29 +0100 Subject: [PATCH 17/59] Fixed missing null check --- module/Core/src/Tag/Model/OrderableField.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index eb802a7f..818099de 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -23,7 +23,7 @@ enum OrderableField: string public static function toSnakeCaseValidField(?string $field): string { - $parsed = self::tryFrom($field); + $parsed = $field !== null ? self::tryFrom($field) : self::VISITS; $normalized = match ($parsed) { self::VISITS_COUNT, null => self::VISITS, default => $parsed, From 42f7a68ba59ca3c7d62d7ac78255253f01fd90ba Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 5 Jan 2023 18:50:49 +0100 Subject: [PATCH 18/59] Updated dev container base images --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f3affecb..ca0064b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,7 +102,7 @@ services: shlink_db_mysql: container_name: shlink_db_mysql - image: mysql:5.7 + image: mysql:8.0 ports: - "3307:3306" volumes: @@ -175,7 +175,7 @@ services: shlink_mercure: container_name: shlink_mercure - image: dunglas/mercure:v0.13 + image: dunglas/mercure:v0.14 ports: - "3080:80" environment: From 85464f0fbbd9279b40b60bfdffad55c1342e8a6b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 10:44:08 +0100 Subject: [PATCH 19/59] Added ADR with options to support other HTTP methods in short URLs --- ...6-support-any-http-method-in-short-urls.md | 77 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 78 insertions(+) create mode 100644 docs/adr/2023-01-06-support-any-http-method-in-short-urls.md diff --git a/docs/adr/2023-01-06-support-any-http-method-in-short-urls.md b/docs/adr/2023-01-06-support-any-http-method-in-short-urls.md new file mode 100644 index 00000000..d81ba9d7 --- /dev/null +++ b/docs/adr/2023-01-06-support-any-http-method-in-short-urls.md @@ -0,0 +1,77 @@ +# Support any HTTP method in short URLs + +* Status: Accepted +* Date: 2023-01-06 + +## Context and problem statement + +There has been a report that Shlink behaves as if a short URL was not found when the request HTTP method is not `GET`. + +They want it to accept other methods so that they can do things like POSTing stuff that then gets "redirected" to the original URL. + +This presents two main problems: + +* Changing this could be considered a breaking change, in case someone is relying on this behavior (Shlink to only redirect on `GET`). +* Shlink currently supports two redirect statuses ([301](https://httpwg.org/specs/rfc9110.html#status.301) and [302](https://httpwg.org/specs/rfc9110.html#status.302)), which can be configured by the server admin. + + For historical reasons, a client might switch from the original method to `GET` when any of these is returned, not resulting in the desired behavior anyway. + + Instead, statuses [308](https://httpwg.org/specs/rfc9110.html#status.308) and [307](https://httpwg.org/specs/rfc9110.html#status.307) should be used. + +## Considered options + +There's actually two problems to solve here. Some combinations are implicitly required: + +* **To support other HTTP methods in short URLs** + * Start supporting all HTTP methods. + * Introduce a feature flag to allow users decide if they want to support all methods or just `GET`. +* **To support other redirects statuses (308 and 307)** + * Switch to status 308 and 307 and stop using 301 and 302. + * Allow users to configure which of the 4 status codes they want to use, insteadof just supporting 301 and 302. + * Allow users to configure between two combinations: 301+308 and 302+307, using 301 or 302 for `GET` requests, and 308 or 307 for the rest. + +> **Note** +> I asked on social networks, and these were the results (not too many answers though): +> * https://fosstodon.org/@shlinkio/109626773392324128 +> * https://twitter.com/shlinkio/status/1610347091741507585 + +## Decision outcome + +Because of backwards compatibility, it feels like the bets option is allowing to configure between 301, 302, 308 and 307. + +This has the benefit that we can keep existing behavior intact. Existing instances will continue working only on `GET`, with statuses 301 or 302. + +Anyone who wants to opt-in, can switch to 308 or 307, and the short URLs will transparently work on other HTTP methods in that case. + +The only drawback is that this difference in the behavior when 308 or 307 are configured needs to be documented, and explained in shlink-installer. + +## Pros and Cons of the Options + +### Start supporting all HTTP methods + +* Good: Because the change in code is pretty simple. +* Bad: Because it would be potentially a breaking change for anyone trusting current behavior for anything. + +### Support HTTP methods via feature flag + +* Good: because it would be safer for existing instances and opt-in for anyone interested in this change of behavior. +* Bad: Because it requires more changes in code. +* Bad: Because it requires a new config entry in the shlink-installer. + +### Switch to statuses 308 and 307 + +* Good: Because we keep supporting just two status codes. +* Bad: Because it requires applying mapping/transformation to convert old configurations. +* Bad: Because it requires changes in shlink-installer. + +### Allow users to configure between 301, 302, 308 and 307 + +* Good: Because it's fully backwards compatible with existing configs. +* Good: Because it would implicitly allow enabling all HTTP methods if 308 or 307 are selected, and keep only `GET` for 301 and 302, without the need for a separated feature flag. +* Bad: Because it requires dynamically supporting only `GET` or all methods, depending on the selected status. + +### Allow users to configure between 301+308 or 302+307 + +* Good: Because it would allow a more explicit redirects config, where values are not 301 and 302, but something like "permanent" and "temporary". +* Bad: Because it implicitly changes the behavior of existing instances, making them respond to redirects with a method other than `GET`, and with a status code other than the one they explicitly configured. +* Bad: because existing `REDIRECT_STATUS_CODE` env var might not make sense anymore, requiring a new one and logic to map from one to another. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7cfccdf7..9d87a0fb 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-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) * [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md) From 390bc59d995d5466f2e895892651e3965ca316a5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 11:27:15 +0100 Subject: [PATCH 20/59] Added support for redirect status code 307 and 308 --- config/autoload/redirects.global.php | 2 +- config/constants.php | 4 ++-- module/Core/src/Options/RedirectOptions.php | 11 +++++----- .../Core/src/Util/RedirectResponseHelper.php | 7 +++---- module/Core/src/Util/RedirectStatus.php | 20 +++++++++++++++++++ .../test/Util/RedirectResponseHelperTest.php | 6 +++++- 6 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 module/Core/src/Util/RedirectStatus.php diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 426bb2ac..26a3c032 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -16,7 +16,7 @@ return [ ], 'redirects' => [ - 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE), + 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value), 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv( DEFAULT_REDIRECT_CACHE_LIFETIME, ), diff --git a/config/constants.php b/config/constants.php index d3d869c3..f6d5e9aa 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Fig\Http\Message\StatusCodeInterface; +use Shlinkio\Shlink\Core\Util\RedirectStatus; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; const MIN_SHORT_CODES_LENGTH = 4; -const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND; +const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag diff --git a/module/Core/src/Options/RedirectOptions.php b/module/Core/src/Options/RedirectOptions.php index 9a1fedac..dd9f0a6d 100644 --- a/module/Core/src/Options/RedirectOptions.php +++ b/module/Core/src/Options/RedirectOptions.php @@ -4,23 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use function Functional\contains; +use Fig\Http\Message\StatusCodeInterface; +use Shlinkio\Shlink\Core\Util\RedirectStatus; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; final class RedirectOptions { - public readonly int $redirectStatusCode; + public readonly RedirectStatus $redirectStatusCode; public readonly int $redirectCacheLifetime; public function __construct( - int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE, + int $redirectStatusCode = StatusCodeInterface::STATUS_FOUND, int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME, ) { - $this->redirectStatusCode = contains([301, 302], $redirectStatusCode) - ? $redirectStatusCode - : DEFAULT_REDIRECT_STATUS_CODE; + $this->redirectStatusCode = RedirectStatus::tryFrom($redirectStatusCode) ?? DEFAULT_REDIRECT_STATUS_CODE; $this->redirectCacheLifetime = $redirectCacheLifetime > 0 ? $redirectCacheLifetime : DEFAULT_REDIRECT_CACHE_LIFETIME; diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index dfc87480..01e581a7 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Util; -use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\RedirectResponse; use Psr\Http\Message\ResponseInterface; use Shlinkio\Shlink\Core\Options\RedirectOptions; @@ -13,17 +12,17 @@ use function sprintf; class RedirectResponseHelper implements RedirectResponseHelperInterface { - public function __construct(private RedirectOptions $options) + public function __construct(private readonly RedirectOptions $options) { } public function buildRedirectResponse(string $location): ResponseInterface { $statusCode = $this->options->redirectStatusCode; - $headers = $statusCode === StatusCodeInterface::STATUS_FOUND ? [] : [ + $headers = ! $statusCode->allowsCache() ? [] : [ 'Cache-Control' => sprintf('private,max-age=%s', $this->options->redirectCacheLifetime), ]; - return new RedirectResponse($location, $statusCode, $headers); + return new RedirectResponse($location, $statusCode->value, $headers); } } diff --git a/module/Core/src/Util/RedirectStatus.php b/module/Core/src/Util/RedirectStatus.php new file mode 100644 index 00000000..d14e4fea --- /dev/null +++ b/module/Core/src/Util/RedirectStatus.php @@ -0,0 +1,20 @@ + [302, 20, 302, null]; - yield 'status over 302' => [400, 20, 302, null]; + yield 'status 307' => [307, 20, 307, null]; + yield 'status over 308' => [400, 20, 302, null]; yield 'status below 301' => [201, 20, 302, null]; yield 'status 301 with valid expiration' => [301, 20, 301, 'private,max-age=20']; yield 'status 301 with zero expiration' => [301, 0, 301, 'private,max-age=30']; yield 'status 301 with negative expiration' => [301, -20, 301, 'private,max-age=30']; + yield 'status 308 with valid expiration' => [308, 20, 308, 'private,max-age=20']; + yield 'status 308 with zero expiration' => [308, 0, 308, 'private,max-age=30']; + yield 'status 308 with negative expiration' => [308, -20, 308, 'private,max-age=30']; } private function helper(?RedirectOptions $options = null): RedirectResponseHelper From a06957e9fa05c22085d0a9475147134260e1a4bf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 13:04:46 +0100 Subject: [PATCH 21/59] Moved config post-processors to their own sub-namespace --- config/config.php | 4 ++-- .../Core/src/Config/{ => PostProcessor}/BasePathPrefixer.php | 2 +- .../Config/{ => PostProcessor}/MultiSegmentSlugProcessor.php | 2 +- module/Core/src/Util/RedirectStatus.php | 5 +++++ .../test/Config/{ => PostProcessor}/BasePathPrefixerTest.php | 4 ++-- .../{ => PostProcessor}/MultiSegmentSlugProcessorTest.php | 4 ++-- 6 files changed, 13 insertions(+), 8 deletions(-) rename module/Core/src/Config/{ => PostProcessor}/BasePathPrefixer.php (94%) rename module/Core/src/Config/{ => PostProcessor}/MultiSegmentSlugProcessor.php (93%) rename module/Core/test/Config/{ => PostProcessor}/BasePathPrefixerTest.php (92%) rename module/Core/test/Config/{ => PostProcessor}/MultiSegmentSlugProcessorTest.php (92%) diff --git a/config/config.php b/config/config.php index 15a45348..a002c329 100644 --- a/config/config.php +++ b/config/config.php @@ -48,6 +48,6 @@ return (new ConfigAggregator\ConfigAggregator([ // Routes have to be loaded last new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), ], 'data/cache/app_config.php', [ - Core\Config\BasePathPrefixer::class, - Core\Config\MultiSegmentSlugProcessor::class, + Core\Config\PostProcessor\BasePathPrefixer::class, + Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, ]))->getMergedConfig(); diff --git a/module/Core/src/Config/BasePathPrefixer.php b/module/Core/src/Config/PostProcessor/BasePathPrefixer.php similarity index 94% rename from module/Core/src/Config/BasePathPrefixer.php rename to module/Core/src/Config/PostProcessor/BasePathPrefixer.php index 4a306287..619e6056 100644 --- a/module/Core/src/Config/BasePathPrefixer.php +++ b/module/Core/src/Config/PostProcessor/BasePathPrefixer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Config; +namespace Shlinkio\Shlink\Core\Config\PostProcessor; use function Functional\map; diff --git a/module/Core/src/Config/MultiSegmentSlugProcessor.php b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php similarity index 93% rename from module/Core/src/Config/MultiSegmentSlugProcessor.php rename to module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php index b9cf2457..b84491f6 100644 --- a/module/Core/src/Config/MultiSegmentSlugProcessor.php +++ b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Config; +namespace Shlinkio\Shlink\Core\Config\PostProcessor; use function Functional\map; use function str_replace; diff --git a/module/Core/src/Util/RedirectStatus.php b/module/Core/src/Util/RedirectStatus.php index d14e4fea..a36719d2 100644 --- a/module/Core/src/Util/RedirectStatus.php +++ b/module/Core/src/Util/RedirectStatus.php @@ -17,4 +17,9 @@ enum RedirectStatus: int { return contains([self::STATUS_301, self::STATUS_308], $this); } + + public function isLegacyStatus(): bool + { + return contains([self::STATUS_301, self::STATUS_302], $this); + } } diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/PostProcessor/BasePathPrefixerTest.php similarity index 92% rename from module/Core/test/Config/BasePathPrefixerTest.php rename to module/Core/test/Config/PostProcessor/BasePathPrefixerTest.php index 2298a59c..90c1449a 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/PostProcessor/BasePathPrefixerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Config; +namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\BasePathPrefixer; +use Shlinkio\Shlink\Core\Config\PostProcessor\BasePathPrefixer; class BasePathPrefixerTest extends TestCase { diff --git a/module/Core/test/Config/MultiSegmentSlugProcessorTest.php b/module/Core/test/Config/PostProcessor/MultiSegmentSlugProcessorTest.php similarity index 92% rename from module/Core/test/Config/MultiSegmentSlugProcessorTest.php rename to module/Core/test/Config/PostProcessor/MultiSegmentSlugProcessorTest.php index 630a5d90..cef07a86 100644 --- a/module/Core/test/Config/MultiSegmentSlugProcessorTest.php +++ b/module/Core/test/Config/PostProcessor/MultiSegmentSlugProcessorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Config; +namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\MultiSegmentSlugProcessor; +use Shlinkio\Shlink\Core\Config\PostProcessor\MultiSegmentSlugProcessor; class MultiSegmentSlugProcessorTest extends TestCase { From 0c1b36d0d4b29baf9aa7563aecd112181693e362 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 13:51:35 +0100 Subject: [PATCH 22/59] Added config post-processor which sets proper allowed methods based on redirect status codes --- config/config.php | 1 + config/constants.php | 2 +- .../ShortUrlMethodsProcessor.php | 41 +++++++ .../ShortUrlMethodsProcessorTest.php | 105 ++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php create mode 100644 module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php diff --git a/config/config.php b/config/config.php index a002c329..8fe311a0 100644 --- a/config/config.php +++ b/config/config.php @@ -50,4 +50,5 @@ return (new ConfigAggregator\ConfigAggregator([ ], 'data/cache/app_config.php', [ Core\Config\PostProcessor\BasePathPrefixer::class, Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, + Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, ]))->getMergedConfig(); diff --git a/config/constants.php b/config/constants.php index f6d5e9aa..5c891a34 100644 --- a/config/constants.php +++ b/config/constants.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Util\RedirectStatus; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; const MIN_SHORT_CODES_LENGTH = 4; -const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; +const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4 const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag diff --git a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php new file mode 100644 index 00000000..05ecdb6c --- /dev/null +++ b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php @@ -0,0 +1,41 @@ + $route['name'] === RedirectAction::class, + ); + if (count($redirectRoutes) === 0) { + return $config; + } + + [$redirectRoute] = array_values($redirectRoutes); + $redirectStatus = RedirectStatus::tryFrom( + $config['redirects']['redirect_status_code'] ?? 0, + ) ?? DEFAULT_REDIRECT_STATUS_CODE; + $redirectRoute['allowed_methods'] = $redirectStatus->isLegacyStatus() + ? [RequestMethodInterface::METHOD_GET] + : Route::HTTP_METHOD_ANY; + + $config['routes'] = [...$rest, $redirectRoute]; + return $config; + } +} diff --git a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php new file mode 100644 index 00000000..b73253f7 --- /dev/null +++ b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php @@ -0,0 +1,105 @@ +processor = new ShortUrlMethodsProcessor(); + } + + /** + * @test + * @dataProvider provideConfigs + */ + public function onlyFirstRouteIdentifiedAsRedirectIsEditedWithProperAllowedMethods( + array $config, + ?array $expectedRoutes, + ): void { + self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? null); + } + + public function provideConfigs(): iterable + { + $buildConfigWithStatus = static fn (int $status, ?array $expectedAllowedMethods) => [[ + 'routes' => [ + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => RedirectAction::class], + ], + 'redirects' => [ + 'redirect_status_code' => $status, + ], + ], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => $expectedAllowedMethods, + ], + ]]; + + yield 'empty config' => [[], null]; + yield 'empty routes' => [['routes' => []], []]; + yield 'no redirects route' => [['routes' => $routes = [ + ['name' => 'foo'], + ['name' => 'bar'], + ]], $routes]; + yield 'one redirects route' => [['routes' => [ + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => RedirectAction::class], + ]], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ]]; + yield 'one redirects route in different location' => [['routes' => [ + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['POST'], + ], + ['name' => 'foo'], + ['name' => 'bar'], + ]], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ]]; + yield 'multiple redirects routes' => [['routes' => [ + ['name' => RedirectAction::class], + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => RedirectAction::class], + ['name' => RedirectAction::class], + ]], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ]]; + yield 'one redirects route with invalid status code' => $buildConfigWithStatus(500, ['GET']); + yield 'one redirects route with 302 status code' => $buildConfigWithStatus(302, ['GET']); + yield 'one redirects route with 301 status code' => $buildConfigWithStatus(301, ['GET']); + yield 'one redirects route with 307 status code' => $buildConfigWithStatus(307, Route::HTTP_METHOD_ANY); + yield 'one redirects route with 308 status code' => $buildConfigWithStatus(308, Route::HTTP_METHOD_ANY); + } +} From cc292886a61a62f2e8ad7362e330d1bfdd2bad14 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 13:55:46 +0100 Subject: [PATCH 23/59] Updated changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9b5cf3..a7389d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Added * [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint. * [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint. +* [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308. + + Existing Shlink instances will continue to work the same. However, if you decide to set the redirect status codes as 307 or 308, Shlink will also return a redirect for short URLs even when the request method is different from `GET`. + + The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method. ### Changed * *Nothing* From 3e98485c8b07a6a857ae601bf01a71a55486a458 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 17:02:34 +0100 Subject: [PATCH 24/59] Updated to installer supporting redirect status codes 308 and 307 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ddf41fa5..a3b90d76 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "shlinkio/shlink-config": "^2.3", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", - "shlinkio/shlink-installer": "^8.2", + "shlinkio/shlink-installer": "dev-develop#5fcee9b as 8.3", "shlinkio/shlink-ip-geolocation": "^3.2", "spiral/roadrunner": "^2.11", "spiral/roadrunner-jobs": "^2.5", From edaf999bf522c3f08b4ddd1065128e13ae83420b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Jan 2023 17:09:53 +0100 Subject: [PATCH 25/59] Fixed constant assignment on enum which is not valid for PHP 8.1 --- module/Core/src/Util/RedirectStatus.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Util/RedirectStatus.php b/module/Core/src/Util/RedirectStatus.php index a36719d2..76c047f4 100644 --- a/module/Core/src/Util/RedirectStatus.php +++ b/module/Core/src/Util/RedirectStatus.php @@ -2,16 +2,14 @@ namespace Shlinkio\Shlink\Core\Util; -use Fig\Http\Message\StatusCodeInterface; - use function Functional\contains; enum RedirectStatus: int { - case STATUS_301 = StatusCodeInterface::STATUS_MOVED_PERMANENTLY; - case STATUS_302 = StatusCodeInterface::STATUS_FOUND; - case STATUS_307 = StatusCodeInterface::STATUS_TEMPORARY_REDIRECT; - case STATUS_308 = StatusCodeInterface::STATUS_PERMANENT_REDIRECT; + case STATUS_301 = 301; // StatusCodeInterface::STATUS_MOVED_PERMANENTLY; + case STATUS_302 = 302; // StatusCodeInterface::STATUS_FOUND; + case STATUS_307 = 307; // StatusCodeInterface::STATUS_TEMPORARY_REDIRECT; + case STATUS_308 = 308; // StatusCodeInterface::STATUS_PERMANENT_REDIRECT; public function allowsCache(): bool { From 21863e8de6761d4a94dddfd44df98201232d2fd3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 12 Jan 2023 19:26:36 +0100 Subject: [PATCH 26/59] Add support to load openswoole-specific config via env vars --- CHANGELOG.md | 2 ++ composer.json | 2 +- config/autoload/swoole.global.php | 3 +++ module/Core/test/Config/NotFoundRedirectResolverTest.php | 2 +- module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php | 2 +- module/Rest/test/Middleware/AuthenticationMiddlewareTest.php | 2 +- 6 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7389d9d..a7a45727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method. +* [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`. + ### Changed * *Nothing* diff --git a/composer.json b/composer.json index a3b90d76..85608742 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.5", "shlinkio/shlink-common": "^5.2", - "shlinkio/shlink-config": "^2.3", + "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", "shlinkio/shlink-installer": "dev-develop#5fcee9b as 8.3", diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index 36cba24f..494e3cf2 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use function Shlinkio\Shlink\Config\getOpenswooleConfigFromEnv; + use const Shlinkio\Shlink\MIN_TASK_WORKERS; return (static function (): array { @@ -21,6 +23,7 @@ return (static function (): array { 'process-name' => 'shlink', 'options' => [ + ...getOpenswooleConfigFromEnv(), 'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16), 'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS), ], diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index d2d03807..4ffa0024 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -135,7 +135,7 @@ class NotFoundRedirectResolverTest extends TestCase RouteResult::class, RouteResult::fromRoute( new Route( - '', + 'foo', $this->createMock(MiddlewareInterface::class), ['GET'], $routeName, diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index aa6302a3..800dc4e0 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -55,7 +55,7 @@ class NotFoundTemplateHandlerTest extends TestCase RouteResult::class, RouteResult::fromRoute( new Route( - '', + 'foo', $this->createMock(MiddlewareInterface::class), ['GET'], RedirectAction::class, diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 3eb77dcb..62ca5aef 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -95,7 +95,7 @@ class AuthenticationMiddlewareTest extends TestCase { $baseRequest = fn (string $routeName) => ServerRequestFactory::fromGlobals()->withAttribute( RouteResult::class, - RouteResult::fromRoute(new Route($routeName, $this->getDummyMiddleware()), []), + RouteResult::fromRoute(new Route($routeName, $this->getDummyMiddleware())), // @phpstan-ignore-line ); $apiKeyMessage = 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided'; $queryMessage = 'Expected authentication to be provided in "apiKey" query param'; From 80e3f015622ee5f1826d6265177529cb4f4219dc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 19 Jan 2023 09:05:52 +0100 Subject: [PATCH 27/59] Replace references to doma.in with s.test --- CHANGELOG.md | 12 ++++---- config/test/test_config.global.php | 4 +-- data/infra/examples/apache-vhost.conf | 2 +- data/infra/examples/nginx-vhost.conf | 2 +- docker/README.md | 4 +-- docs/async-api/async-api.json | 4 +-- docs/swagger/paths/v1_short-urls.json | 6 ++-- docs/swagger/paths/v1_short-urls_shorten.json | 4 +-- .../paths/v1_short-urls_{shortCode}.json | 4 +-- docs/swagger/paths/v2_visits_orphan.json | 6 ++-- .../test-cli/Command/ListShortUrlsTest.php | 24 ++++++++-------- .../Domain/GetDomainVisitsCommandTest.php | 2 +- .../Repository/ShortUrlRepositoryTest.php | 28 +++++++++---------- .../Visit/Repository/VisitRepositoryTest.php | 20 ++++++------- module/Core/test/Action/QrCodeActionTest.php | 2 +- .../Config/NotFoundRedirectResolverTest.php | 12 ++++---- .../Exception/DeleteShortUrlExceptionTest.php | 6 ++-- .../Helper/ShortCodeUniquenessHelperTest.php | 2 +- .../ExtraPathRedirectMiddlewareTest.php | 2 +- .../ShortUrl/Model/ShortUrlCreationTest.php | 2 +- ...ersistenceShortUrlRelationResolverTest.php | 2 +- .../OrphanVisitDataTransformerTest.php | 4 +-- .../test-api/Action/CreateShortUrlTest.php | 4 +-- .../test-api/Action/DomainRedirectsTest.php | 2 +- .../Rest/test-api/Action/ListDomainsTest.php | 4 +-- .../test-api/Action/ListShortUrlsTest.php | 8 +++--- .../Rest/test-api/Action/OrphanVisitsTest.php | 6 ++-- .../Rest/test-api/Fixtures/VisitsFixture.php | 6 ++-- .../Domain/DomainRedirectsActionTest.php | 2 +- .../ShortUrl/ListShortUrlsActionTest.php | 2 +- ...ortUrlContentNegotiationMiddlewareTest.php | 2 +- ...DefaultDomainFromRequestMiddlewareTest.php | 6 ++-- .../ShortUrl/OverrideDomainMiddlewareTest.php | 4 +-- 33 files changed, 100 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a45727..55ce701d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1453,7 +1453,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain. - Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance. + Custom slugs can be created on multiple domains, allowing to share links like `https://s.test/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance. When resolving a short URL to redirect end users, the following rules are applied: @@ -1916,7 +1916,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ```json { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", "visitsCount": 1029, @@ -1983,7 +1983,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service. * [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range. - For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05` + For example: `https://s.test/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05` * [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances. @@ -2055,7 +2055,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This eases integration with third party services. - With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format. + With this feature, a simple request to a URL like `https://s.test/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format. ### Changed * *Nothing* @@ -2091,7 +2091,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Added * [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection. - Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened. + Useful to track emails. Just add an image pointing to a URL like `https://s.test/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened. * [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests @@ -2372,7 +2372,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Added * [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL. - In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code` + In order to get the QR code URL, use a pattern like `https://s.test/abc123/qr-code` * [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory * [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 368a5f4e..ac62f8a6 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -84,7 +84,7 @@ $buildDbConnection = static function (): array { return match ($driver) { 'sqlite' => [ 'driver' => 'pdo_sqlite', - 'path' => sys_get_temp_dir() . '/shlink-tests.db', + 'memory' => true, ], 'postgres' => [ 'driver' => 'pdo_pgsql', @@ -131,7 +131,7 @@ return [ 'url_shortener' => [ 'domain' => [ 'schema' => 'http', - 'hostname' => 'doma.in', + 'hostname' => 's.test', ], ], diff --git a/data/infra/examples/apache-vhost.conf b/data/infra/examples/apache-vhost.conf index fbb7a18a..872001a3 100644 --- a/data/infra/examples/apache-vhost.conf +++ b/data/infra/examples/apache-vhost.conf @@ -1,5 +1,5 @@ - ServerName doma.in + ServerName s.test DocumentRoot "/path/to/shlink/public" diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 5e05481a..6cd4dd4e 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -1,5 +1,5 @@ server { - server_name doma.in; + server_name s.test; listen 80; root /path/to/shlink/public; index index.php; diff --git a/docker/README.md b/docker/README.md index c1279b2d..629a9ee1 100644 --- a/docker/README.md +++ b/docker/README.md @@ -11,7 +11,7 @@ It exposes a shlink instance served with [openswoole](https://openswoole.com/), The most basic way to run Shlink's docker image is by providing these mandatory env vars. -* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**. +* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **s.test**. * `IS_HTTPS_ENABLED`: Either **true** or **false**. Tells if Shlink is being served with HTTPs or not. * `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this. @@ -21,7 +21,7 @@ To run shlink on top of a local docker service, and using an internal SQLite dat docker run \ --name shlink \ -p 8080:8080 \ - -e DEFAULT_DOMAIN=doma.in \ + -e DEFAULT_DOMAIN=s.test \ -e IS_HTTPS_ENABLED=true \ -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \ shlinkio/shlink:stable diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 6ce83784..418409cf 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -150,7 +150,7 @@ }, "example": { "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", + "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { @@ -292,7 +292,7 @@ "timezone": "America/Los_Angeles" }, "potentialBot": false, - "visitedUrl": "https://doma.in", + "visitedUrl": "https://s.test", "type": "base_url" } }, diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 8960234a..2bd461d8 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -161,7 +161,7 @@ "data": [ { "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", + "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { @@ -184,7 +184,7 @@ }, { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { @@ -318,7 +318,7 @@ }, "example": { "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", + "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 254a88f2..e0257c59 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -52,7 +52,7 @@ }, "example": { "longUrl": "https://github.com/shlinkio/shlink", - "shortUrl": "https://doma.in/abc123", + "shortUrl": "https://s.test/abc123", "shortCode": "abc123", "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { @@ -78,7 +78,7 @@ "schema": { "type": "string" }, - "example": "https://doma.in/abc123" + "example": "https://s.test/abc123" } } }, diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 00577f4f..bd69b4ab 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -38,7 +38,7 @@ }, "example": { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { @@ -160,7 +160,7 @@ }, "example": { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json index 03d56553..b10ac37f 100644 --- a/docs/swagger/paths/v2_visits_orphan.json +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -95,7 +95,7 @@ "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", "visitLocation": null, "potentialBot": false, - "visitedUrl": "https://doma.in", + "visitedUrl": "https://s.test", "type": "base_url" }, { @@ -112,7 +112,7 @@ "timezone": "America/Los_Angeles" }, "potentialBot": false, - "visitedUrl": "https://doma.in/foo", + "visitedUrl": "https://s.test/foo", "type": "invalid_short_url" }, { @@ -121,7 +121,7 @@ "userAgent": "some_web_crawler/1.4", "visitLocation": null, "potentialBot": true, - "visitedUrl": "https://doma.in/foo/bar/baz", + "visitedUrl": "https://s.test/foo/bar/baz", "type": "regular_404" } ], diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php index c98573a5..8b92d2f0 100644 --- a/module/CLI/test-cli/Command/ListShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -27,11 +27,11 @@ class ListShortUrlsTest extends CliTestCase | Short Code | Title | Short URL | Long URL | Date created | Visits count | +--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+ | ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 | - | custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 | - | def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 | + | custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 | + | def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 | | custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 | - | abc123 | My cool title | http://doma.in/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 | - | ghi789 | | http://doma.in/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 | + | abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 | + | ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 | +--------------------+---------------+-------------------------------------------+---------------------------- Page 1 of 1 ------------------------------------------------------------------+---------------------------+--------------+ OUTPUT]; yield 'start date' => [['--start-date=2019-01'], << [['-e 2018-12-01'], << [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], <<locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); - $domain = 'doma.in'; + $domain = 's.test'; $this->visitsHelper->expects($this->once())->method('visitsForDomain')->with( $domain, $this->anything(), diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index c842bcb4..ed500349 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -41,7 +41,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($withDomain); $withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData( - ['domain' => 'doma.in', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], + ['domain' => 's.test', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], )); $this->getEntityManager()->persist($withDomainDuplicatingRegular); @@ -59,7 +59,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame( $withDomainDuplicatingRegular, $this->repo->findOneWithDomainFallback( - ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 's.test'), ), ); self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain( @@ -84,7 +84,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrlWithoutDomain); $shortUrlWithDomain = ShortUrl::create( - ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), + ShortUrlCreation::fromRawData(['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); @@ -92,7 +92,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertTrue($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug'))); self::assertFalse($this->repo->shortCodeIsInUse( - ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 's.test'), )); self::assertFalse($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('slug-not-in-use'))); self::assertFalse($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('another-slug'))); @@ -100,7 +100,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'example.com'), )); self::assertTrue($this->repo->shortCodeIsInUse( - ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 's.test'), )); } @@ -113,21 +113,21 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrlWithoutDomain); $shortUrlWithDomain = ShortUrl::create( - ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), + ShortUrlCreation::fromRawData(['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->flush(); self::assertNotNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug'))); - self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 'doma.in'))); + self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 's.test'))); self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('slug-not-in-use'))); self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('another-slug'))); self::assertNull($this->repo->findOne( ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'example.com'), )); self::assertNotNull($this->repo->findOne( - ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 's.test'), )); } @@ -175,7 +175,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl5); - $shortUrl6 = ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])); + $shortUrl6 = ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl6); $this->getEntityManager()->flush(); @@ -212,7 +212,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ); self::assertSame( $shortUrl6, - $this->repo->findOneMatching(ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])), + $this->repo->findOneMatching(ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'foo'])), ); } @@ -379,16 +379,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); $this->getEntityManager()->persist($shortUrlWithoutDomain); - $shortUrlWithDomain = ShortUrl::fromImport($buildImported('another-slug', 'doma.in'), true); + $shortUrlWithDomain = ShortUrl::fromImport($buildImported('another-slug', 's.test'), true); $this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->flush(); self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug'))); - self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 'doma.in'))); + self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 's.test'))); self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug'))); - self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 'doma.in'))); - self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 'doma.in'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 's.test'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 's.test'))); self::assertNull($this->repo->findOneByImportedUrl($buildImported('another-slug'))); } } diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index eb806208..2e509aa2 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -208,17 +208,17 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findVisitsByDomainReturnsProperData(): void { - $this->createShortUrlsAndVisits('doma.in'); + $this->createShortUrlsAndVisits('s.test'); $this->getEntityManager()->flush(); self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering())); self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering())); - self::assertCount(3, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering())); - self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); - self::assertCount(2, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + self::assertCount(3, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering())); + self::assertCount(1, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering(null, true))); + self::assertCount(2, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + self::assertCount(1, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( @@ -232,17 +232,17 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countVisitsByDomainReturnsProperData(): void { - $this->createShortUrlsAndVisits('doma.in'); + $this->createShortUrlsAndVisits('s.test'); $this->getEntityManager()->flush(); self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering())); self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering())); - self::assertEquals(3, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering())); - self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); - self::assertEquals(2, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + self::assertEquals(3, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering())); + self::assertEquals(1, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering(null, true))); + self::assertEquals(2, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + self::assertEquals(1, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 89c105c0..599a09d9 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -241,7 +241,7 @@ class QrCodeActionTest extends TestCase { return new QrCodeAction( $this->urlResolver, - new ShortUrlStringifier(['domain' => 'doma.in']), + new ShortUrlStringifier(['domain' => 's.test']), new NullLogger(), $options ?? new QrCodeOptions(), ); diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index 4ffa0024..53531b15 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -61,16 +61,16 @@ class NotFoundRedirectResolverTest extends TestCase 'baseUrl', ]; yield 'base URL with domain placeholder' => [ - $uri = new Uri('https://doma.in'), + $uri = new Uri('https://s.test'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/{DOMAIN}'), - 'https://redirect-here.com/doma.in', + 'https://redirect-here.com/s.test', ]; yield 'base URL with domain placeholder in query' => [ - $uri = new Uri('https://doma.in'), + $uri = new Uri('https://s.test'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), - 'https://redirect-here.com/?domain=doma.in', + 'https://redirect-here.com/?domain=s.test', ]; yield 'base URL without trailing slash' => [ $uri = new Uri(''), @@ -91,12 +91,12 @@ class NotFoundRedirectResolverTest extends TestCase 'https://redirect-here.com/?path=%2Ffoo%2Fbar', ]; yield 'regular 404 with multiple placeholders' => [ - $uri = new Uri('https://doma.in/foo/bar'), + $uri = new Uri('https://s.test/foo/bar'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions( regular404: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', ), - 'https://redirect-here.com/foo/bar/doma.in/?d=doma.in&p=%2Ffoo%2Fbar', + 'https://redirect-here.com/foo/bar/s.test/?d=s.test&p=%2Ffoo%2Fbar', ]; yield 'invalid short URL' => [ new Uri('/foo'), diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index df1edaaa..bfdce269 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -57,14 +57,14 @@ class DeleteShortUrlExceptionTest extends TestCase { $e = DeleteShortUrlException::fromVisitsThreshold( 10, - ShortUrlIdentifier::fromShortCodeAndDomain('abc123', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('abc123', 's.test'), ); - $expectedMessage = 'Impossible to delete short URL with short code "abc123" for domain "doma.in", since it ' + $expectedMessage = 'Impossible to delete short URL with short code "abc123" for domain "s.test", since it ' . 'has more than "10" visits.'; self::assertEquals([ 'shortCode' => 'abc123', - 'domain' => 'doma.in', + 'domain' => 's.test', 'threshold' => 10, ], $e->getAdditionalData()); self::assertEquals($expectedMessage, $e->getMessage()); diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index cc18be07..5df79fc5 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -57,7 +57,7 @@ class ShortCodeUniquenessHelperTest extends TestCase public function provideDomains(): iterable { yield 'no domain' => [null, null]; - yield 'domain' => [Domain::withAuthority($authority = 'doma.in'), $authority]; + yield 'domain' => [Domain::withAuthority($authority = 's.test'), $authority]; } /** @test */ diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 355bec0e..835d1487 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -141,7 +141,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $type->method('isRegularNotFound')->willReturn(true); $type->method('isInvalidShortUrl')->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) - ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); + ->withUri(new Uri('https://s.test/shortCode/bar/baz')); $shortUrl = ShortUrl::withLongUrl(''); $currentIteration = 1; diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 51457264..ee9e540a 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -166,6 +166,6 @@ class ShortUrlCreationTest extends TestCase yield 'null domain' => [null, null]; yield 'empty domain' => ['', null]; yield 'trimmable domain' => [' ', null]; - yield 'valid domain' => ['doma.in', 'doma.in']; + yield 'valid domain' => ['s.test', 's.test']; } } diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 37a9f2e2..8734b95c 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -57,7 +57,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase public function provideFoundDomains(): iterable { - $authority = 'doma.in'; + $authority = 's.test'; yield 'not found domain' => [null, $authority]; yield 'found domain' => [Domain::withAuthority($authority), $authority]; diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php index 059afcad..2f38a17a 100644 --- a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php +++ b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php @@ -69,7 +69,7 @@ class OrphanVisitDataTransformerTest extends TestCase Visitor::fromRequest( ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'user-agent') ->withHeader('Referer', 'referer') - ->withUri(new Uri('https://doma.in/foo/bar')), + ->withUri(new Uri('https://s.test/foo/bar')), ), )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), [ @@ -78,7 +78,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'userAgent' => 'user-agent', 'visitLocation' => $location, 'potentialBot' => false, - 'visitedUrl' => 'https://doma.in/foo/bar', + 'visitedUrl' => 'https://s.test/foo/bar', 'type' => VisitType::REGULAR_404->value, ], ]; diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 889b67af..5190000e 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -297,7 +297,7 @@ class CreateShortUrlTest extends ApiTestCase { [$createStatusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([ 'longUrl' => 'https://www.alejandrocelaya.com', - 'domain' => 'doma.in', + 'domain' => 's.test', ]); $getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode); $payload = $this->getJsonResponsePayload($getResp); @@ -359,7 +359,7 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_OK, $statusCode); self::assertEquals('πŸ”₯πŸ”₯πŸ”₯', $payload['title']); self::assertEquals('🦣🦣🦣', $payload['shortCode']); - self::assertEquals('http://doma.in/🦣🦣🦣', $payload['shortUrl']); + self::assertEquals('http://s.test/🦣🦣🦣', $payload['shortUrl']); } /** diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php index fdeec3b3..7abd4d5e 100644 --- a/module/Rest/test-api/Action/DomainRedirectsTest.php +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -61,7 +61,7 @@ class DomainRedirectsTest extends ApiTestCase 'invalidShortUrlRedirect' => null, ]]; yield 'default domain' => [[ - 'domain' => 'doma.in', + 'domain' => 's.test', 'regular404Redirect' => 'foo-for-default.com', ], [ 'baseUrlRedirect' => null, diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index 54039c41..42f011c7 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -34,7 +34,7 @@ class ListDomainsTest extends ApiTestCase { yield 'admin API key' => ['valid_api_key', [ [ - 'domain' => 'doma.in', + 'domain' => 's.test', 'isDefault' => true, 'redirects' => [ 'baseUrlRedirect' => null, @@ -72,7 +72,7 @@ class ListDomainsTest extends ApiTestCase ]]; yield 'author API key' => ['author_api_key', [ [ - 'domain' => 'doma.in', + 'domain' => 's.test', 'isDefault' => true, 'redirects' => [ 'baseUrlRedirect' => null, diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index d3a515c1..43d466e3 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -14,7 +14,7 @@ class ListShortUrlsTest extends ApiTestCase { private const SHORT_URL_SHLINK_WITH_TITLE = [ 'shortCode' => 'abc123', - 'shortUrl' => 'http://doma.in/abc123', + 'shortUrl' => 'http://s.test/abc123', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2018-05-01T00:00:00+00:00', 'visitsCount' => 3, @@ -36,7 +36,7 @@ class ListShortUrlsTest extends ApiTestCase ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', - 'shortUrl' => 'http://doma.in/ghi789', + 'shortUrl' => 'http://s.test/ghi789', 'longUrl' => 'https://shlink.io/documentation/', 'dateCreated' => '2018-05-01T00:00:00+00:00', 'visitsCount' => 2, @@ -80,7 +80,7 @@ class ListShortUrlsTest extends ApiTestCase ]; private const SHORT_URL_META = [ 'shortCode' => 'def456', - 'shortUrl' => 'http://doma.in/def456', + 'shortUrl' => 'http://s.test/def456', 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09' . '/acmailer-7-0-the-most-important-release-in-a-long-time/', @@ -104,7 +104,7 @@ class ListShortUrlsTest extends ApiTestCase ]; private const SHORT_URL_CUSTOM_SLUG = [ 'shortCode' => 'custom', - 'shortUrl' => 'http://doma.in/custom', + 'shortUrl' => 'http://s.test/custom', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2019-01-01T00:00:20+00:00', 'visitsCount' => 0, diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index a37193da..eee35d73 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class OrphanVisitsTest extends ApiTestCase { private const INVALID_SHORT_URL = [ - 'referer' => 'https://doma.in/foo', + 'referer' => 'https://s.test/foo', 'date' => '2020-03-01T00:00:00+00:00', 'userAgent' => 'cf-facebook', 'visitLocation' => null, @@ -20,7 +20,7 @@ class OrphanVisitsTest extends ApiTestCase 'type' => 'invalid_short_url', ]; private const REGULAR_NOT_FOUND = [ - 'referer' => 'https://doma.in/foo/bar', + 'referer' => 'https://s.test/foo/bar', 'date' => '2020-02-01T00:00:00+00:00', 'userAgent' => 'shlink-tests-agent', 'visitLocation' => null, @@ -29,7 +29,7 @@ class OrphanVisitsTest extends ApiTestCase 'type' => 'regular_404', ]; private const BASE_URL = [ - 'referer' => 'https://doma.in', + 'referer' => 'https://s.test', 'date' => '2020-01-01T00:00:00+00:00', 'userAgent' => 'shlink-tests-agent', 'visitLocation' => null, diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index ada0ebad..6076f95e 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -50,15 +50,15 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface ); $manager->persist($this->setVisitDate( - Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://doma.in', '1.2.3.4', '')), + Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')), '2020-01-01', )); $manager->persist($this->setVisitDate( - Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://doma.in/foo/bar', '1.2.3.4', '')), + Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4', '')), '2020-02-01', )); $manager->persist($this->setVisitDate( - Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://doma.in/foo', '1.2.3.4', 'foo.com')), + Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')), '2020-03-01', )); diff --git a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php index 5ff409a0..df5fed43 100644 --- a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php +++ b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php @@ -60,7 +60,7 @@ class DomainRedirectsActionTest extends TestCase array $redirects, array $expectedResult, ): void { - $authority = 'doma.in'; + $authority = 's.test'; $redirects['domain'] = $authority; $apiKey = ApiKey::create(); $request = ServerRequestFactory::fromGlobals()->withParsedBody($redirects) diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 4164e78b..329c9717 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -29,7 +29,7 @@ class ListShortUrlsActionTest extends TestCase $this->action = new ListShortUrlsAction($this->service, new ShortUrlDataTransformer( new ShortUrlStringifier([ - 'hostname' => 'doma.in', + 'hostname' => 's.test', 'schema' => 'https', ]), )); diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index 58a3f34d..adfaf43e 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -51,7 +51,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase $this->requestHandler->expects($this->once())->method('handle')->with( $this->isInstanceOf(ServerRequestInterface::class), - )->willReturn(new JsonResponse(['shortUrl' => 'http://doma.in/foo'])); + )->willReturn(new JsonResponse(['shortUrl' => 'http://s.test/foo'])); $response = $this->middleware->process($request, $this->requestHandler); diff --git a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php index 1af34a48..cc3ff21c 100644 --- a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php @@ -21,7 +21,7 @@ class DropDefaultDomainFromRequestMiddlewareTest extends TestCase protected function setUp(): void { $this->next = $this->createMock(RequestHandlerInterface::class); - $this->middleware = new DropDefaultDomainFromRequestMiddleware('doma.in'); + $this->middleware = new DropDefaultDomainFromRequestMiddleware('s.test'); } /** @@ -47,8 +47,8 @@ class DropDefaultDomainFromRequestMiddlewareTest extends TestCase { yield [[], []]; yield [['foo' => 'bar'], ['foo' => 'bar']]; - yield [['foo' => 'bar', 'domain' => 'doma.in'], ['foo' => 'bar']]; + yield [['foo' => 'bar', 'domain' => 's.test'], ['foo' => 'bar']]; yield [['foo' => 'bar', 'domain' => 'not_default'], ['foo' => 'bar', 'domain' => 'not_default']]; - yield [['domain' => 'doma.in'], []]; + yield [['domain' => 's.test'], []]; } } diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php index ad558abf..f91e9818 100644 --- a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -88,9 +88,9 @@ class OverrideDomainMiddlewareTest extends TestCase [ShortUrlInputFilter::DOMAIN => 'baz.com'], ]; yield 'more body params' => [ - Domain::withAuthority('doma.in'), + Domain::withAuthority('s.test'), [ShortUrlInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], - [ShortUrlInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], + [ShortUrlInputFilter::DOMAIN => 's.test', 'something' => 'else', 'foo' => 123], ]; } From 407134bab1b560bbb37cb9b2af624b76e89caa74 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Jan 2023 09:57:35 +0100 Subject: [PATCH 28/59] Extract docker image building during CI to its own workflow --- .github/workflows/ci-docker-image-build.yml | 14 ++++++++ .github/workflows/ci.yml | 34 +++++++++---------- ...age-build.yml => publish-docker-image.yml} | 8 +++++ LICENSE | 2 +- 4 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci-docker-image-build.yml rename .github/workflows/{docker-image-build.yml => publish-docker-image.yml} (82%) diff --git a/.github/workflows/ci-docker-image-build.yml b/.github/workflows/ci-docker-image-build.yml new file mode 100644 index 00000000..690a365d --- /dev/null +++ b/.github/workflows/ci-docker-image-build.yml @@ -0,0 +1,14 @@ +name: Build docker image + +on: + pull_request: + paths: + - './Dockerfile' + +jobs: + build-docker-image: + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + - run: docker build -t shlink-docker-image:temp . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca34c07d..a95f8f21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,28 @@ name: Continuous integration on: - pull_request: null + pull_request: + paths-ignore: + - 'LICENSE' + - './.*' + - './*.md' + - './*.xml' + - './*.yml*' + - './*.json5' + - './*.neon' push: branches: - main - develop - 2.x + paths-ignore: + - 'LICENSE' + - './.*' + - './*.md' + - './*.xml' + - './*.yml*' + - './*.json5' + - './*.neon' jobs: static-analysis: @@ -157,19 +173,3 @@ jobs: coverage-db coverage-api coverage-cli - - build-docker-image: - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 100 - - uses: marceloprado/has-changed-path@v1 - id: changed-dockerfile - with: - paths: ./Dockerfile - - if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }} - run: docker build -t shlink-docker-image:temp . - - if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }} - run: echo "Dockerfile didn't change. Skipped" diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/publish-docker-image.yml similarity index 82% rename from .github/workflows/docker-image-build.yml rename to .github/workflows/publish-docker-image.yml index 9eb682d6..2842f505 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/publish-docker-image.yml @@ -4,6 +4,14 @@ on: push: branches: - develop + paths-ignore: + - 'LICENSE' + - './.*' + - './*.md' + - './*.xml' + - './*.yml*' + - './*.json5' + - './*.neon' tags: - 'v*' diff --git a/LICENSE b/LICENSE index 2a381d83..c245a4e0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2021 Alejandro Celaya +Copyright (c) 2016-2023 Alejandro Celaya Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 12150f775ddacace6c02c8cb0c812ce9826d950a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Jan 2023 13:45:39 +0100 Subject: [PATCH 29/59] Created persistence for device long URLs --- composer.json | 1 + config/config.php | 3 +- data/migrations/Version20230103105343.php | 53 +++++++++++++++++++ ...ink.Core.ShortUrl.Entity.DeviceLongUrl.php | 41 ++++++++++++++ ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 2 +- module/Core/functions/functions.php | 18 +++++++ module/Core/src/Config/EnvVars.php | 10 ---- module/Core/src/Model/DeviceType.php | 28 ++++++++++ .../src/ShortUrl/Entity/DeviceLongUrl.php | 18 +++++++ .../src/ShortUrl/Model/OrderableField.php | 9 ---- module/Core/src/ShortUrl/Model/TagsMode.php | 7 --- .../Validation/ShortUrlsParamsInputFilter.php | 6 ++- module/Core/test/Config/EnvVarsTest.php | 8 --- module/Core/test/Functions/FunctionsTest.php | 43 +++++++++++++++ 14 files changed, 209 insertions(+), 38 deletions(-) create mode 100644 data/migrations/Version20230103105343.php create mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php create mode 100644 module/Core/src/Model/DeviceType.php create mode 100644 module/Core/src/ShortUrl/Entity/DeviceLongUrl.php create mode 100644 module/Core/test/Functions/FunctionsTest.php diff --git a/composer.json b/composer.json index 85608742..1ad2e7ab 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "mezzio/mezzio-problem-details": "^1.7", "mezzio/mezzio-swoole": "^4.5", "mlocati/ip-lib": "^1.18", + "mobiledetect/mobiledetectlib": "^3.74", "ocramius/proxy-manager": "^2.14", "pagerfanta/core": "^3.6", "php-middleware/request-id": "^4.1", diff --git a/config/config.php b/config/config.php index 8fe311a0..e0ec6c23 100644 --- a/config/config.php +++ b/config/config.php @@ -15,6 +15,7 @@ use function class_exists; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\openswooleIsInstalled; use function Shlinkio\Shlink\Config\runningInRoadRunner; +use function Shlinkio\Shlink\Core\enumValues; use const PHP_SAPI; @@ -23,7 +24,7 @@ $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoad return (new ConfigAggregator\ConfigAggregator([ ! $isTestEnv - ? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values()) + ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class)) : new ConfigAggregator\ArrayProvider([]), Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, diff --git a/data/migrations/Version20230103105343.php b/data/migrations/Version20230103105343.php new file mode 100644 index 00000000..c61a8a94 --- /dev/null +++ b/data/migrations/Version20230103105343.php @@ -0,0 +1,53 @@ +skipIf($schema->hasTable(self::TABLE_NAME)); + + $table = $schema->createTable(self::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('device_type', Types::STRING, ['length' => 255]); + $table->addColumn('long_url', Types::STRING, ['length' => 2048]); + $table->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url'); + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable(self::TABLE_NAME)); + $schema->dropTable(self::TABLE_NAME); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php new file mode 100644 index 00000000..8de69c18 --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php @@ -0,0 +1,41 @@ +setTable(determineTableName('device_long_urls', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + (new FieldBuilder($builder, [ + 'fieldName' => 'deviceType', + 'type' => Types::STRING, + 'enumType' => DeviceType::class, + ]))->columnName('device_type') + ->length(255) + ->build(); + + fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) + ->columnName('long_url') + ->length(2048) + ->build(); + + $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) + ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') + ->build(); +}; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 6b769f34..b67996ae 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -24,7 +24,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) - ->columnName('original_url') + ->columnName('original_url') // Rename to long_url some day? Β―\_(ツ)_/Β― ->length(2048) ->build(); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 9d0b8d68..574d604c 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; +use BackedEnum; use Cake\Chronos\Chronos; use Cake\Chronos\ChronosInterface; use DateTimeInterface; @@ -16,6 +17,7 @@ use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; use function date_default_timezone_get; +use function Functional\map; use function Functional\reduce_left; use function is_array; use function print_r; @@ -159,3 +161,19 @@ function toProblemDetailsType(string $errorCode): string { return sprintf('https://shlink.io/api/error/%s', $errorCode); } + +/** + * @param class-string $enum + * @return string[] + */ +function enumValues(string $enum): array +{ + static $cache; + if ($cache === null) { + $cache = []; + } + + return $cache[$enum] ?? ( + $cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value) + ); +} diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 228a5921..75454ecc 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -use function Functional\map; use function Shlinkio\Shlink\Config\env; enum EnvVars: string @@ -77,13 +76,4 @@ enum EnvVars: string { return $this->loadFromEnv() !== null; } - - /** - * @return string[] - */ - public static function values(): array - { - static $values; - return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value)); - } } diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php new file mode 100644 index 00000000..df4a1838 --- /dev/null +++ b/module/Core/src/Model/DeviceType.php @@ -0,0 +1,28 @@ +is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only +// $detect->is('iOS') && ! $detect->isTablet() => self::IOS, // TODO To detect iPhone only +// $detect->is('androidOS') && $detect->isTablet() => self::ANDROID, // TODO To detect Android tablets +// $detect->is('androidOS') && ! $detect->isTablet() => self::ANDROID, // TODO To detect Android phones + $detect->is('iOS') => self::IOS, // Detects both iPhone and iPad + $detect->is('androidOS') => self::ANDROID, // Detects both android phones and android tablets + ! $detect->isMobile() && ! $detect->isTablet() => self::DESKTOP, + default => null, + }; + } +} diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php new file mode 100644 index 00000000..faf9bcc3 --- /dev/null +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -0,0 +1,18 @@ + $field->value); - } - public static function isBasicField(string $value): bool { return contains( diff --git a/module/Core/src/ShortUrl/Model/TagsMode.php b/module/Core/src/ShortUrl/Model/TagsMode.php index 01cdcc3b..593d6d83 100644 --- a/module/Core/src/ShortUrl/Model/TagsMode.php +++ b/module/Core/src/ShortUrl/Model/TagsMode.php @@ -4,15 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Model; -use function Functional\map; - enum TagsMode: string { case ANY = 'any'; case ALL = 'all'; - - public static function values(): array - { - return map(self::cases(), static fn (TagsMode $mode) => $mode->value); - } } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index cb120e8e..d7cda41e 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; +use function Shlinkio\Shlink\Core\enumValues; + class ShortUrlsParamsInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -46,12 +48,12 @@ class ShortUrlsParamsInputFilter extends InputFilter $tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode->getValidatorChain()->attach(new InArray([ - 'haystack' => TagsMode::values(), + 'haystack' => enumValues(TagsMode::class), 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); - $this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values())); + $this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class))); $this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); $this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index ff4878de..6d4b1394 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\Config; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EnvVars; -use function Functional\map; use function putenv; class EnvVarsTest extends TestCase @@ -59,11 +58,4 @@ class EnvVarsTest extends TestCase yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; } - - /** @test */ - public function allValuesCanBeListed(): void - { - $expected = map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value); - self::assertEquals(EnvVars::values(), $expected); - } } diff --git a/module/Core/test/Functions/FunctionsTest.php b/module/Core/test/Functions/FunctionsTest.php new file mode 100644 index 00000000..5ba6a7db --- /dev/null +++ b/module/Core/test/Functions/FunctionsTest.php @@ -0,0 +1,43 @@ + [EnvVars::class, map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value)]; + yield VisitType::class => [ + VisitType::class, + map(VisitType::cases(), static fn (VisitType $envVar) => $envVar->value), + ]; + yield DeviceType::class => [ + DeviceType::class, + map(DeviceType::cases(), static fn (DeviceType $envVar) => $envVar->value), + ]; + yield OrderableField::class => [ + OrderableField::class, + map(OrderableField::cases(), static fn (OrderableField $envVar) => $envVar->value), + ]; + } +} From 1447687ebe9ae18af58ef4882e06f52efe56da65 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Jan 2023 15:19:47 +0100 Subject: [PATCH 30/59] Add deviceLongUrls to short URL creation --- .../ShortUrl/CreateShortUrlCommandTest.php | 7 +- .../src/ShortUrl/Entity/DeviceLongUrl.php | 14 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 27 +-- .../src/ShortUrl/Model/ShortUrlCreation.php | 198 +++++++----------- .../Model/Validation/ShortUrlInputFilter.php | 49 ++++- .../Repository/ShortUrlRepository.php | 28 +-- .../ShortUrlRepositoryInterface.php | 2 +- module/Core/src/ShortUrl/UrlShortener.php | 6 +- .../Tag/Repository/TagRepositoryTest.php | 6 +- .../Visit/Repository/VisitRepositoryTest.php | 20 +- .../NotifyNewShortUrlToMercureTest.php | 4 +- .../PublishingUpdatesGeneratorTest.php | 8 +- .../NotifyNewShortUrlToRabbitMqTest.php | 4 +- .../RabbitMq/NotifyVisitToRabbitMqTest.php | 4 +- .../NotifyNewShortUrlToRedisTest.php | 2 +- module/Core/test/Functions/FunctionsTest.php | 2 + .../test/ShortUrl/Entity/ShortUrlTest.php | 6 +- .../Helper/ShortUrlStringifierTest.php | 2 +- .../ExtraPathRedirectMiddlewareTest.php | 2 +- .../ShortUrl/Model/ShortUrlCreationTest.php | 32 +-- .../test/ShortUrl/ShortUrlResolverTest.php | 8 +- .../ShortUrlDataTransformerTest.php | 15 +- .../test-api/Action/CreateShortUrlTest.php | 5 +- 23 files changed, 222 insertions(+), 229 deletions(-) diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 734089c9..1a8df888 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -100,9 +100,8 @@ class CreateShortUrlCommandTest extends TestCase { $shortUrl = ShortUrl::createEmpty(); $this->urlShortener->expects($this->once())->method('shorten')->with( - $this->callback(function (ShortUrlCreation $meta) { - $tags = $meta->getTags(); - Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags); + $this->callback(function (ShortUrlCreation $creation) { + Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags); return true; }), )->willReturn($shortUrl); @@ -128,7 +127,7 @@ class CreateShortUrlCommandTest extends TestCase { $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) { - Assert::assertEquals($expectedDomain, $meta->getDomain()); + Assert::assertEquals($expectedDomain, $meta->domain); return true; }), )->willReturn(ShortUrl::createEmpty()); diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php index faf9bcc3..b1dc1086 100644 --- a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -9,10 +9,20 @@ use Shlinkio\Shlink\Core\Model\DeviceType; class DeviceLongUrl extends AbstractEntity { - private function __construct( + public function __construct( public readonly ShortUrl $shortUrl, public readonly DeviceType $deviceType, - public readonly string $longUrl, + private string $longUrl, ) { } + + public function longUrl(): string + { + return $this->longUrl; + } + + public function updateLongUrl(string $longUrl): void + { + $this->longUrl = $longUrl; + } } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 0ebdeb24..6c49e1c3 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -60,6 +60,9 @@ class ShortUrl extends AbstractEntity return self::create(ShortUrlCreation::createEmpty()); } + /** + * @param non-empty-string $longUrl + */ public static function withLongUrl(string $longUrl): self { return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl])); @@ -75,19 +78,19 @@ class ShortUrl extends AbstractEntity $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->tags = $relationResolver->resolveTags($creation->getTags()); - $instance->validSince = $creation->getValidSince(); - $instance->validUntil = $creation->getValidUntil(); - $instance->maxVisits = $creation->getMaxVisits(); + $instance->tags = $relationResolver->resolveTags($creation->tags); + $instance->validSince = $creation->validSince; + $instance->validUntil = $creation->validUntil; + $instance->maxVisits = $creation->maxVisits; $instance->customSlugWasProvided = $creation->hasCustomSlug(); - $instance->shortCodeLength = $creation->getShortCodeLength(); - $instance->shortCode = $creation->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); - $instance->domain = $relationResolver->resolveDomain($creation->getDomain()); - $instance->authorApiKey = $creation->getApiKey(); - $instance->title = $creation->getTitle(); - $instance->titleWasAutoResolved = $creation->titleWasAutoResolved(); - $instance->crawlable = $creation->isCrawlable(); - $instance->forwardQuery = $creation->forwardQuery(); + $instance->shortCodeLength = $creation->shortCodeLength; + $instance->shortCode = $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength); + $instance->domain = $relationResolver->resolveDomain($creation->domain); + $instance->authorApiKey = $creation->apiKey; + $instance->title = $creation->title; + $instance->titleWasAutoResolved = $creation->titleWasAutoResolved; + $instance->crawlable = $creation->crawlable; + $instance->forwardQuery = $creation->forwardQuery; return $instance; } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index bbdd9ab0..e2af5cf1 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -6,85 +6,106 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function Functional\map; use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\normalizeOptionalDate; +use function trim; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class ShortUrlCreation implements TitleResolutionModelInterface { - private string $longUrl; - private ?Chronos $validSince = null; - private ?Chronos $validUntil = null; - private ?string $customSlug = null; - private ?int $maxVisits = null; - private ?bool $findIfExists = null; - private ?string $domain = null; - private int $shortCodeLength = 5; - private bool $validateUrl = false; - private ?ApiKey $apiKey = null; - private array $tags = []; - private ?string $title = null; - private bool $titleWasAutoResolved = false; - private bool $crawlable = false; - private bool $forwardQuery = true; - - private function __construct() - { + /** + * @param string[] $tags + * @param array{DeviceType, string}[] $deviceLongUrls + */ + private function __construct( + public readonly string $longUrl, + public readonly array $deviceLongUrls = [], + public readonly ?Chronos $validSince = null, + public readonly ?Chronos $validUntil = null, + public readonly ?string $customSlug = null, + public readonly ?int $maxVisits = null, + public readonly bool $findIfExists = false, + public readonly ?string $domain = null, + public readonly int $shortCodeLength = 5, + public readonly bool $validateUrl = false, + public readonly ?ApiKey $apiKey = null, + public readonly array $tags = [], + public readonly ?string $title = null, + public readonly bool $titleWasAutoResolved = false, + public readonly bool $crawlable = false, + public readonly bool $forwardQuery = true, + ) { } public static function createEmpty(): self { - $instance = new self(); - $instance->longUrl = ''; - - return $instance; + return new self(''); } /** * @throws ValidationException */ public static function fromRawData(array $data): self - { - $instance = new self(); - $instance->validateAndInit($data); - - return $instance; - } - - /** - * @throws ValidationException - */ - private function validateAndInit(array $data): void { $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); - $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); - $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); - $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); - $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; - $this->domain = getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN); - $this->shortCodeLength = getOptionalIntFromInputFilter( - $inputFilter, - ShortUrlInputFilter::SHORT_CODE_LENGTH, - ) ?? DEFAULT_SHORT_CODES_LENGTH; - $this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY); - $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); - $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); - $this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); - $this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; + return new self( + longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), + deviceLongUrls: map( + $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], + static fn (string $longUrl, string $deviceType) => [DeviceType::from($deviceType), trim($longUrl)], + ), + validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), + validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), + customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), + maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), + findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false, + domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN), + shortCodeLength: getOptionalIntFromInputFilter( + $inputFilter, + ShortUrlInputFilter::SHORT_CODE_LENGTH, + ) ?? DEFAULT_SHORT_CODES_LENGTH, + validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, + apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY), + tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), + title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), + crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), + forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true, + ); + } + + public function withResolvedTitle(string $title): self + { + return new self( + $this->longUrl, + $this->deviceLongUrls, + $this->validSince, + $this->validUntil, + $this->customSlug, + $this->maxVisits, + $this->findIfExists, + $this->domain, + $this->shortCodeLength, + $this->validateUrl, + $this->apiKey, + $this->tags, + $title, + true, + $this->crawlable, + $this->forwardQuery, + ); } public function getLongUrl(): string @@ -92,115 +113,38 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return $this->longUrl; } - public function getValidSince(): ?Chronos - { - return $this->validSince; - } - public function hasValidSince(): bool { return $this->validSince !== null; } - public function getValidUntil(): ?Chronos - { - return $this->validUntil; - } - public function hasValidUntil(): bool { return $this->validUntil !== null; } - public function getCustomSlug(): ?string - { - return $this->customSlug; - } - public function hasCustomSlug(): bool { return $this->customSlug !== null; } - public function getMaxVisits(): ?int - { - return $this->maxVisits; - } - public function hasMaxVisits(): bool { return $this->maxVisits !== null; } - public function findIfExists(): bool - { - return (bool) $this->findIfExists; - } - public function hasDomain(): bool { return $this->domain !== null; } - public function getDomain(): ?string - { - return $this->domain; - } - - public function getShortCodeLength(): int - { - return $this->shortCodeLength; - } - public function doValidateUrl(): bool { return $this->validateUrl; } - public function getApiKey(): ?ApiKey - { - return $this->apiKey; - } - - /** - * @return string[] - */ - public function getTags(): array - { - return $this->tags; - } - - public function getTitle(): ?string - { - return $this->title; - } - public function hasTitle(): bool { return $this->title !== null; } - - public function titleWasAutoResolved(): bool - { - return $this->titleWasAutoResolved; - } - - public function withResolvedTitle(string $title): self - { - $copy = clone $this; - $copy->title = $title; - $copy->titleWasAutoResolved = true; - - return $copy; - } - - public function isCrawlable(): bool - { - return $this->crawlable; - } - - public function forwardQuery(): bool - { - return $this->forwardQuery; - } } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index a6c5627f..f31ee294 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -6,14 +6,20 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; use DateTime; use Laminas\Filter; -use Laminas\InputFilter\Input; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function array_keys; +use function array_values; +use function Functional\contains; +use function Functional\every; +use function is_array; use function is_string; +use function Shlinkio\Shlink\Core\enumValues; use function str_replace; use function substr; use function trim; @@ -32,6 +38,7 @@ class ShortUrlInputFilter extends InputFilter public const DOMAIN = 'domain'; public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const LONG_URL = 'longUrl'; + public const DEVICE_LONG_URLS = 'deviceLongUrls'; public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; public const TAGS = 'tags'; @@ -57,16 +64,40 @@ class ShortUrlInputFilter extends InputFilter private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void { - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); - $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ + $notEmptyValidator = new Validator\NotEmpty([ Validator\NotEmpty::OBJECT, Validator\NotEmpty::SPACE, Validator\NotEmpty::NULL, Validator\NotEmpty::EMPTY_ARRAY, Validator\NotEmpty::BOOLEAN, - ])); + Validator\NotEmpty::STRING, + ]); + + $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput->getValidatorChain()->attach($notEmptyValidator); $this->add($longUrlInput); + $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); + $deviceLongUrlsInput->getValidatorChain()->attach( + new Validator\Callback(function (mixed $value) use ($notEmptyValidator): bool { + if (! is_array($value)) { + // TODO Set proper error: Not array + return false; + } + + $validValues = enumValues(DeviceType::class); + $keys = array_keys($value); + if (! every($keys, static fn ($key) => contains($validValues, $key))) { + // TODO Set proper error: Provided invalid device type + return false; + } + + $longUrls = array_values($value); + return every($longUrls, $notEmptyValidator->isValid(...)); + }), + ); + $this->add($deviceLongUrlsInput); + $validSince = $this->createInput(self::VALID_SINCE, false); $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); $this->add($validSince); @@ -75,8 +106,8 @@ class ShortUrlInputFilter extends InputFilter $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); $this->add($validUntil); - // FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's - // empty, is by using the deprecated setContinueIfEmpty + // 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 $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, @@ -102,10 +133,8 @@ class ShortUrlInputFilter extends InputFilter $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); - $apiKeyInput = new Input(self::API_KEY); - $apiKeyInput - ->setRequired(false) - ->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $apiKeyInput = $this->createInput(self::API_KEY, false); + $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); $this->add($apiKeyInput); $this->add($this->createTagsInput(self::TAGS, false)); diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index 5e95f777..ee2f7389 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -101,45 +101,45 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb; } - public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl + public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->select('s') ->from(ShortUrl::class, 's') ->where($qb->expr()->eq('s.longUrl', ':longUrl')) - ->setParameter('longUrl', $meta->getLongUrl()) + ->setParameter('longUrl', $creation->longUrl) ->setMaxResults(1) ->orderBy('s.id'); - if ($meta->hasCustomSlug()) { + if ($creation->hasCustomSlug()) { $qb->andWhere($qb->expr()->eq('s.shortCode', ':slug')) - ->setParameter('slug', $meta->getCustomSlug()); + ->setParameter('slug', $creation->customSlug); } - if ($meta->hasMaxVisits()) { + if ($creation->hasMaxVisits()) { $qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits')) - ->setParameter('maxVisits', $meta->getMaxVisits()); + ->setParameter('maxVisits', $creation->maxVisits); } - if ($meta->hasValidSince()) { + if ($creation->hasValidSince()) { $qb->andWhere($qb->expr()->eq('s.validSince', ':validSince')) - ->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME); + ->setParameter('validSince', $creation->validSince, ChronosDateTimeType::CHRONOS_DATETIME); } - if ($meta->hasValidUntil()) { + if ($creation->hasValidUntil()) { $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil')) - ->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME); + ->setParameter('validUntil', $creation->validUntil, ChronosDateTimeType::CHRONOS_DATETIME); } - if ($meta->hasDomain()) { + if ($creation->hasDomain()) { $qb->join('s.domain', 'd') ->andWhere($qb->expr()->eq('d.authority', ':domain')) - ->setParameter('domain', $meta->getDomain()); + ->setParameter('domain', $creation->domain); } - $apiKey = $meta->getApiKey(); + $apiKey = $creation->apiKey; if ($apiKey !== null) { $this->applySpecification($qb, $apiKey->spec(), 's'); } - $tags = $meta->getTags(); + $tags = $creation->tags; $tagsAmount = count($tags); if ($tagsAmount === 0) { return $qb->getQuery()->getOneOrNullResult(); diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index cc574ac5..18a4ec71 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -22,7 +22,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; - public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl; + public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl; public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index d3f54650..4236189c 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -57,15 +57,15 @@ class UrlShortener implements UrlShortenerInterface return $newShortUrl; } - private function findExistingShortUrlIfExists(ShortUrlCreation $meta): ?ShortUrl + private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl { - if (! $meta->findIfExists()) { + if (! $creation->findIfExists) { return null; } /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - return $repo->findOneMatching($meta); + return $repo->findOneMatching($creation); } private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 57b3a795..4365731a 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -75,7 +75,7 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags] = array_chunk($names, 3); $secondUrlTags = [$names[0]]; $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( - ['longUrl' => '', 'tags' => $tags, 'apiKey' => $apiKey], + ['longUrl' => 'longUrl', 'tags' => $tags, 'apiKey' => $apiKey], ); $shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver); @@ -242,14 +242,14 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3); $shortUrl = ShortUrl::create( - ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '', 'tags' => $firstUrlTags]), + ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => 'longUrl', 'tags' => $firstUrlTags]), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); $shortUrl2 = ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->getAuthority(), 'longUrl' => '', 'tags' => $secondUrlTags], + ['domain' => $domain->getAuthority(), 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], ), $this->relationResolver, ); diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 2e509aa2..df4c5334 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -264,7 +264,9 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::create( - ShortUrlCreation::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), + ShortUrlCreation::fromRawData( + ['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => 'longUrl'], + ), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); @@ -272,12 +274,14 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey2); - $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => 'longUrl'])); $this->getEntityManager()->persist($shortUrl2); $this->createVisitsForShortUrl($shortUrl2, 5); $shortUrl3 = ShortUrl::create( - ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']), + ShortUrlCreation::fromRawData( + ['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => 'longUrl'], + ), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl3); @@ -315,7 +319,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl'])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); @@ -364,7 +368,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl'])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); @@ -460,7 +464,7 @@ class VisitRepositoryTest extends DatabaseTestCase } /** - * @return array{string, string, \Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl} + * @return array{string, string, ShortUrl} */ private function createShortUrlsAndVisits( bool|string $withDomain = true, @@ -468,7 +472,7 @@ class VisitRepositoryTest extends DatabaseTestCase ?ApiKey $apiKey = null, ): array { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ - ShortUrlInputFilter::LONG_URL => '', + ShortUrlInputFilter::LONG_URL => 'longUrl', ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::API_KEY => $apiKey, ]), $this->relationResolver); @@ -482,7 +486,7 @@ class VisitRepositoryTest extends DatabaseTestCase $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => $shortCode, 'domain' => $domain, - 'longUrl' => '', + 'longUrl' => 'longUrl', ])); $this->getEntityManager()->persist($shortUrlWithDomain); $this->createVisitsForShortUrl($shortUrlWithDomain, 3); diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php index c42bd915..855f6c14 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -57,7 +57,7 @@ class NotifyNewShortUrlToMercureTest extends TestCase /** @test */ public function expectedNotificationIsPublished(): void { - $shortUrl = ShortUrl::withLongUrl(''); + $shortUrl = ShortUrl::withLongUrl('longUrl'); $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn($shortUrl); @@ -74,7 +74,7 @@ class NotifyNewShortUrlToMercureTest extends TestCase /** @test */ public function messageIsPrintedIfPublishingFails(): void { - $shortUrl = ShortUrl::withLongUrl(''); + $shortUrl = ShortUrl::withLongUrl('longUrl'); $update = Update::forTopicAndPayload('', []); $e = new Exception('Error'); diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index cda8fe98..9611df99 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -38,7 +38,7 @@ class PublishingUpdatesGeneratorTest extends TestCase { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', - 'longUrl' => '', + 'longUrl' => 'longUrl', 'title' => $title, ])); $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); @@ -51,7 +51,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), - 'longUrl' => '', + 'longUrl' => 'longUrl', 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => 0, 'tags' => [], @@ -118,7 +118,7 @@ class PublishingUpdatesGeneratorTest extends TestCase { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', - 'longUrl' => '', + 'longUrl' => 'longUrl', 'title' => 'The title', ])); @@ -128,7 +128,7 @@ class PublishingUpdatesGeneratorTest extends TestCase self::assertEquals(['shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), - 'longUrl' => '', + 'longUrl' => 'longUrl', 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => 0, 'tags' => [], diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php index 764f7949..52e9630d 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -68,7 +68,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( - ShortUrl::withLongUrl(''), + ShortUrl::withLongUrl('longUrl'), ); $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->isInstanceOf(ShortUrl::class), @@ -88,7 +88,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( - ShortUrl::withLongUrl(''), + ShortUrl::withLongUrl('longUrl'), ); $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->isInstanceOf(ShortUrl::class), diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 8b7b392c..6211ad2b 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -159,7 +159,7 @@ class NotifyVisitToRabbitMqTest extends TestCase { yield 'legacy non-orphan visit' => [ true, - $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('longUrl'), Visitor::emptyInstance()), noop(...), function (MockObject & PublishingHelperInterface $helper) use ($visit): void { $helper->method('publishUpdate')->with($this->callback(function (Update $update) use ($visit): bool { @@ -190,7 +190,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ]; yield 'non-legacy non-orphan visit' => [ false, - Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('longUrl'), Visitor::emptyInstance()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); $updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php index 0b5dfd27..a913de15 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php @@ -55,7 +55,7 @@ class NotifyNewShortUrlToRedisTest extends TestCase $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( - ShortUrl::withLongUrl(''), + ShortUrl::withLongUrl('longUrl'), ); $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->isInstanceOf(ShortUrl::class), diff --git a/module/Core/test/Functions/FunctionsTest.php b/module/Core/test/Functions/FunctionsTest.php index 5ba6a7db..ad45812f 100644 --- a/module/Core/test/Functions/FunctionsTest.php +++ b/module/Core/test/Functions/FunctionsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Functions; +use BackedEnum; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Model\DeviceType; @@ -16,6 +17,7 @@ use function Shlinkio\Shlink\Core\enumValues; class FunctionsTest extends TestCase { /** + * @param class-string $enum * @test * @dataProvider provideEnums */ diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index 026778ae..fd4515fb 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -38,7 +38,7 @@ class ShortUrlTest extends TestCase public function provideInvalidShortUrls(): iterable { yield 'with custom slug' => [ - ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => ''])), + ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => 'longUrl'])), 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', ]; yield 'already persisted' => [ @@ -66,7 +66,7 @@ class ShortUrlTest extends TestCase { yield 'no custom slug' => [ShortUrl::createEmpty()]; yield 'imported with custom slug' => [ShortUrl::fromImport( - new ImportedShlinkUrl(ImportSource::BITLY, '', [], Chronos::now(), null, 'custom-slug', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'longUrl', [], Chronos::now(), null, 'custom-slug', null), true, )]; } @@ -78,7 +78,7 @@ class ShortUrlTest extends TestCase public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( - [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], + [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => 'longUrl'], )); self::assertEquals($expectedLength, strlen($shortUrl->getShortCode())); diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index b6d5a123..fc8c7579 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -30,7 +30,7 @@ class ShortUrlStringifierTest extends TestCase { $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create( ShortUrlCreation::fromRawData([ - 'longUrl' => '', + 'longUrl' => 'longUrl', 'customSlug' => $shortCode, 'domain' => $domain, ]), diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 835d1487..696a47ab 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -142,7 +142,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $type->method('isInvalidShortUrl')->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) ->withUri(new Uri('https://s.test/shortCode/bar/baz')); - $shortUrl = ShortUrl::withLongUrl(''); + $shortUrl = ShortUrl::withLongUrl('longUrl'); $currentIteration = 1; $this->resolver->expects($this->exactly($expectedResolveCalls))->method('resolveEnabledShortUrl')->with( diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index ee9e540a..33380ecf 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -80,24 +80,24 @@ class ShortUrlCreationTest extends TestCase string $expectedSlug, bool $multiSegmentEnabled = false, ): void { - $meta = ShortUrlCreation::fromRawData([ + $creation = ShortUrlCreation::fromRawData([ 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug, - 'longUrl' => '', + 'longUrl' => 'longUrl', EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, ]); - self::assertTrue($meta->hasValidSince()); - self::assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); + self::assertTrue($creation->hasValidSince()); + self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince); - self::assertFalse($meta->hasValidUntil()); - self::assertNull($meta->getValidUntil()); + self::assertFalse($creation->hasValidUntil()); + self::assertNull($creation->validUntil); - self::assertTrue($meta->hasCustomSlug()); - self::assertEquals($expectedSlug, $meta->getCustomSlug()); + self::assertTrue($creation->hasCustomSlug()); + self::assertEquals($expectedSlug, $creation->customSlug); - self::assertFalse($meta->hasMaxVisits()); - self::assertNull($meta->getMaxVisits()); + self::assertFalse($creation->hasMaxVisits()); + self::assertNull($creation->maxVisits); } public function provideCustomSlugs(): iterable @@ -127,12 +127,12 @@ class ShortUrlCreationTest extends TestCase */ public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void { - $meta = ShortUrlCreation::fromRawData([ + $creation = ShortUrlCreation::fromRawData([ 'title' => $title, - 'longUrl' => '', + 'longUrl' => 'longUrl', ]); - self::assertEquals($expectedTitle, $meta->getTitle()); + self::assertEquals($expectedTitle, $creation->title); } public function provideTitles(): iterable @@ -153,12 +153,12 @@ class ShortUrlCreationTest extends TestCase */ public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void { - $meta = ShortUrlCreation::fromRawData([ + $creation = ShortUrlCreation::fromRawData([ 'domain' => $domain, - 'longUrl' => '', + 'longUrl' => 'longUrl', ]); - self::assertSame($expectedDomain, $meta->getDomain()); + self::assertSame($expectedDomain, $creation->domain); } public function provideDomains(): iterable diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 9c2bcab3..177e432e 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -114,7 +114,7 @@ class ShortUrlResolverTest extends TestCase $now = Chronos::now(); yield 'maxVisits reached' => [(function () { - $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'longUrl'])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), @@ -123,16 +123,16 @@ class ShortUrlResolverTest extends TestCase return $shortUrl; })()]; yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => ''], + ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => 'longUrl'], ))]; yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => ''], + ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => 'longUrl'], ))]; yield 'mixed' => [(function () use ($now) { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => 3, 'validUntil' => $now->subMonth()->toAtomString(), - 'longUrl' => '', + 'longUrl' => 'longUrl', ])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), diff --git a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index c9df4e38..7a97d4da 100644 --- a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -45,7 +45,7 @@ class ShortUrlDataTransformerTest extends TestCase ]]; yield 'max visits only' => [ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => $maxVisits, - 'longUrl' => '', + 'longUrl' => 'longUrl', ])), [ 'validSince' => null, 'validUntil' => null, @@ -53,7 +53,7 @@ class ShortUrlDataTransformerTest extends TestCase ]]; yield 'max visits and valid since' => [ ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => ''], + ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => 'longUrl'], )), [ 'validSince' => $now->toAtomString(), @@ -63,7 +63,7 @@ class ShortUrlDataTransformerTest extends TestCase ]; yield 'both dates' => [ ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => ''], + ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => 'longUrl'], )), [ 'validSince' => $now->toAtomString(), @@ -72,9 +72,12 @@ class ShortUrlDataTransformerTest extends TestCase ], ]; yield 'everything' => [ - ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits, 'longUrl' => ''], - )), + ShortUrl::create(ShortUrlCreation::fromRawData([ + 'validSince' => $now, + 'validUntil' => $now->subDays(5), + 'maxVisits' => $maxVisits, + 'longUrl' => 'longUrl', + ])), [ 'validSince' => $now->toAtomString(), 'validUntil' => $now->subDays(5)->toAtomString(), diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 5190000e..19bd6c74 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -261,9 +261,8 @@ class CreateShortUrlTest extends ApiTestCase public function provideInvalidUrls(): iterable { - yield 'empty URL' => ['', '2', 'INVALID_URL']; - yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; - yield 'API version 3' => ['', '3', 'https://shlink.io/api/error/invalid-url']; + yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; + yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url']; } /** From 822652cac3daff23c6710efaa90f474ef0d7542a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Jan 2023 15:44:12 +0100 Subject: [PATCH 31/59] Allow providing device long URLs during short URL edition --- module/Core/src/ShortUrl/Entity/ShortUrl.php | 19 +- .../src/ShortUrl/Model/DeviceLongUrlPair.php | 35 ++++ .../src/ShortUrl/Model/ShortUrlCreation.php | 43 +++-- .../src/ShortUrl/Model/ShortUrlEdition.php | 167 +++++++----------- .../Model/Validation/ShortUrlInputFilter.php | 2 +- .../test/ShortUrl/ShortUrlServiceTest.php | 8 +- 6 files changed, 140 insertions(+), 134 deletions(-) create mode 100644 module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 6c49e1c3..51bd09ea 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -55,6 +55,9 @@ class ShortUrl extends AbstractEntity { } + /** + * @deprecated This should not be allowed + */ public static function createEmpty(): self { return self::create(ShortUrlCreation::createEmpty()); @@ -226,34 +229,34 @@ class ShortUrl extends AbstractEntity ?ShortUrlRelationResolverInterface $relationResolver = null, ): void { if ($shortUrlEdit->validSinceWasProvided()) { - $this->validSince = $shortUrlEdit->validSince(); + $this->validSince = $shortUrlEdit->validSince; } if ($shortUrlEdit->validUntilWasProvided()) { - $this->validUntil = $shortUrlEdit->validUntil(); + $this->validUntil = $shortUrlEdit->validUntil; } if ($shortUrlEdit->maxVisitsWasProvided()) { - $this->maxVisits = $shortUrlEdit->maxVisits(); + $this->maxVisits = $shortUrlEdit->maxVisits; } if ($shortUrlEdit->longUrlWasProvided()) { - $this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl; + $this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl; } if ($shortUrlEdit->tagsWereProvided()) { $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); - $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); + $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags); } if ($shortUrlEdit->crawlableWasProvided()) { - $this->crawlable = $shortUrlEdit->crawlable(); + $this->crawlable = $shortUrlEdit->crawlable; } if ( $this->title === null || $shortUrlEdit->titleWasProvided() || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) ) { - $this->title = $shortUrlEdit->title(); + $this->title = $shortUrlEdit->title; $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); } if ($shortUrlEdit->forwardQueryWasProvided()) { - $this->forwardQuery = $shortUrlEdit->forwardQuery(); + $this->forwardQuery = $shortUrlEdit->forwardQuery; } } diff --git a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php new file mode 100644 index 00000000..6d0234ec --- /dev/null +++ b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php @@ -0,0 +1,35 @@ + $map + * @return self[] + */ + public static function fromMapToList(array $map): array + { + return array_values(map( + $map, + fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl), + )); + } +} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index e2af5cf1..d63482ec 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -6,17 +6,14 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function Functional\map; use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\normalizeOptionalDate; -use function trim; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -24,7 +21,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface { /** * @param string[] $tags - * @param array{DeviceType, string}[] $deviceLongUrls + * @param DeviceLongUrlPair[] $deviceLongUrls */ private function __construct( public readonly string $longUrl, @@ -46,6 +43,9 @@ final class ShortUrlCreation implements TitleResolutionModelInterface ) { } + /** + * @deprecated This should not be allowed + */ public static function createEmpty(): self { return new self(''); @@ -63,9 +63,8 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - deviceLongUrls: map( + deviceLongUrls: DeviceLongUrlPair::fromMapToList( $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - static fn (string $longUrl, string $deviceType) => [DeviceType::from($deviceType), trim($longUrl)], ), validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), @@ -89,22 +88,22 @@ final class ShortUrlCreation implements TitleResolutionModelInterface public function withResolvedTitle(string $title): self { return new self( - $this->longUrl, - $this->deviceLongUrls, - $this->validSince, - $this->validUntil, - $this->customSlug, - $this->maxVisits, - $this->findIfExists, - $this->domain, - $this->shortCodeLength, - $this->validateUrl, - $this->apiKey, - $this->tags, - $title, - true, - $this->crawlable, - $this->forwardQuery, + longUrl: $this->longUrl, + deviceLongUrls: $this->deviceLongUrls, + validSince: $this->validSince, + validUntil: $this->validUntil, + customSlug: $this->customSlug, + maxVisits: $this->maxVisits, + findIfExists: $this->findIfExists, + domain: $this->domain, + shortCodeLength: $this->shortCodeLength, + validateUrl: $this->validateUrl, + apiKey: $this->apiKey, + tags: $this->tags, + title: $title, + titleWasAutoResolved: true, + crawlable: $this->crawlable, + forwardQuery: $this->forwardQuery, ); } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index fadc9b1e..32451d2e 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -16,77 +16,93 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate; final class ShortUrlEdition implements TitleResolutionModelInterface { - private bool $longUrlPropWasProvided = false; - private ?string $longUrl = null; - private bool $validSincePropWasProvided = false; - private ?Chronos $validSince = null; - private bool $validUntilPropWasProvided = false; - private ?Chronos $validUntil = null; - private bool $maxVisitsPropWasProvided = false; - private ?int $maxVisits = null; - private bool $tagsPropWasProvided = false; - private array $tags = []; - private bool $titlePropWasProvided = false; - private ?string $title = null; - private bool $titleWasAutoResolved = false; - private bool $validateUrl = false; - private bool $crawlablePropWasProvided = false; - private bool $crawlable = false; - private bool $forwardQueryPropWasProvided = false; - private bool $forwardQuery = true; - - private function __construct() - { + /** + * @param string[] $tags + */ + private function __construct( + private readonly bool $longUrlPropWasProvided = false, + public readonly ?string $longUrl = null, + public readonly array $deviceLongUrls = [], + private readonly bool $validSincePropWasProvided = false, + public readonly ?Chronos $validSince = null, + private readonly bool $validUntilPropWasProvided = false, + public readonly ?Chronos $validUntil = null, + private readonly bool $maxVisitsPropWasProvided = false, + public readonly ?int $maxVisits = null, + private readonly bool $tagsPropWasProvided = false, + public readonly array $tags = [], + private readonly bool $titlePropWasProvided = false, + public readonly ?string $title = null, + public readonly bool $titleWasAutoResolved = false, + public readonly bool $validateUrl = false, + private readonly bool $crawlablePropWasProvided = false, + public readonly bool $crawlable = false, + private readonly bool $forwardQueryPropWasProvided = false, + public readonly bool $forwardQuery = true, + ) { } /** * @throws ValidationException */ public static function fromRawData(array $data): self - { - $instance = new self(); - $instance->validateAndInit($data); - return $instance; - } - - /** - * @throws ValidationException - */ - private function validateAndInit(array $data): void { $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data); - $this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data); - $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); - $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); - $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data); - $this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data); - $this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data); - $this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data); - - $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); - $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); - $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; - $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); - $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); - $this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); - $this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; + return new self( + longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data), + longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), + deviceLongUrls: DeviceLongUrlPair::fromMapToList( + $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], + ), + validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data), + validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), + validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data), + validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), + maxVisitsPropWasProvided: array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data), + maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), + tagsPropWasProvided: array_key_exists(ShortUrlInputFilter::TAGS, $data), + tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), + titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data), + title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), + validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, + crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data), + crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), + forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data), + forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true, + ); } - public function longUrl(): ?string + public function withResolvedTitle(string $title): self { - return $this->longUrl; + return new self( + longUrlPropWasProvided: $this->longUrlPropWasProvided, + longUrl: $this->longUrl, + validSincePropWasProvided: $this->validSincePropWasProvided, + validSince: $this->validSince, + validUntilPropWasProvided: $this->validUntilPropWasProvided, + validUntil: $this->validUntil, + maxVisitsPropWasProvided: $this->maxVisitsPropWasProvided, + maxVisits: $this->maxVisits, + tagsPropWasProvided: $this->tagsPropWasProvided, + tags: $this->tags, + titlePropWasProvided: $this->titlePropWasProvided, + title: $title, + titleWasAutoResolved: true, + validateUrl: $this->validateUrl, + crawlablePropWasProvided: $this->crawlablePropWasProvided, + crawlable: $this->crawlable, + forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided, + forwardQuery: $this->forwardQuery, + ); } public function getLongUrl(): string { - return $this->longUrl() ?? ''; + return $this->longUrl ?? ''; } public function longUrlWasProvided(): bool @@ -94,54 +110,26 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->longUrlPropWasProvided && $this->longUrl !== null; } - public function validSince(): ?Chronos - { - return $this->validSince; - } - public function validSinceWasProvided(): bool { return $this->validSincePropWasProvided; } - public function validUntil(): ?Chronos - { - return $this->validUntil; - } - public function validUntilWasProvided(): bool { return $this->validUntilPropWasProvided; } - public function maxVisits(): ?int - { - return $this->maxVisits; - } - public function maxVisitsWasProvided(): bool { return $this->maxVisitsPropWasProvided; } - /** - * @return string[] - */ - public function tags(): array - { - return $this->tags; - } - public function tagsWereProvided(): bool { return $this->tagsPropWasProvided; } - public function title(): ?string - { - return $this->title; - } - public function titleWasProvided(): bool { return $this->titlePropWasProvided; @@ -157,35 +145,16 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->titleWasAutoResolved; } - public function withResolvedTitle(string $title): self - { - $copy = clone $this; - $copy->title = $title; - $copy->titleWasAutoResolved = true; - - return $copy; - } - public function doValidateUrl(): bool { return $this->validateUrl; } - public function crawlable(): bool - { - return $this->crawlable; - } - public function crawlableWasProvided(): bool { return $this->crawlablePropWasProvided; } - public function forwardQuery(): bool - { - return $this->forwardQuery; - } - public function forwardQueryWasProvided(): bool { return $this->forwardQueryPropWasProvided; diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index f31ee294..72708250 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -78,7 +78,7 @@ class ShortUrlInputFilter extends InputFilter $this->add($longUrlInput); $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); - $deviceLongUrlsInput->getValidatorChain()->attach( + $deviceLongUrlsInput->getValidatorChain()->attach( // TODO Extract callback to own validator new Validator\Callback(function (mixed $value) use ($notEmptyValidator): bool { if (! is_array($value)) { // TODO Set proper error: Not array diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 9cc0d955..96e0c9f5 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -73,10 +73,10 @@ class ShortUrlServiceTest extends TestCase ); self::assertSame($shortUrl, $result); - self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); - self::assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); - self::assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); - self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); + self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince()); + self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil()); + self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits()); + self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl()); } public function provideShortUrlEdits(): iterable From 3e26f1113d5ca301bc553fd79a0dacb70b6e8b77 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Jan 2023 16:50:42 +0100 Subject: [PATCH 32/59] Extract device long URL validation to its own validation class --- .../src/ShortUrl/Entity/DeviceLongUrl.php | 11 +++- .../Helper/ShortUrlTitleResolutionHelper.php | 9 ++- ...ShortUrlTitleResolutionHelperInterface.php | 3 + .../Helper/TitleResolutionModelInterface.php | 2 +- .../src/ShortUrl/Model/ShortUrlCreation.php | 2 +- .../src/ShortUrl/Model/ShortUrlEdition.php | 2 +- .../Validation/DeviceLongUrlsValidator.php | 57 +++++++++++++++++++ .../Model/Validation/ShortUrlInputFilter.php | 34 ++--------- .../PersistenceShortUrlRelationResolver.php | 2 +- .../ShortUrlRelationResolverInterface.php | 2 +- .../SimpleShortUrlRelationResolver.php | 2 +- module/Core/src/ShortUrl/ShortUrlService.php | 1 - module/Core/src/ShortUrl/UrlShortener.php | 13 ++--- .../src/ShortUrl/UrlShortenerInterface.php | 2 +- module/Rest/src/Entity/ApiKey.php | 2 +- 15 files changed, 96 insertions(+), 48 deletions(-) create mode 100644 module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php index b1dc1086..b3db666d 100644 --- a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -6,16 +6,23 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Entity; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; class DeviceLongUrl extends AbstractEntity { - public function __construct( - public readonly ShortUrl $shortUrl, + private ShortUrl $shortUrl; // @phpstan-ignore-line + + private function __construct( public readonly DeviceType $deviceType, private string $longUrl, ) { } + public static function fromPair(DeviceLongUrlPair $pair): self + { + return new self($pair->deviceType, $pair->longUrl); + } + public function longUrl(): string { return $this->longUrl; diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 00eecc61..a4920cdd 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -4,14 +4,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; +use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface { - public function __construct(private UrlValidatorInterface $urlValidator) + public function __construct(private readonly UrlValidatorInterface $urlValidator) { } + /** + * @template T of TitleResolutionModelInterface + * @param T $data + * @return T + * @throws InvalidUrlException + */ public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface { if ($data->hasTitle()) { diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php index 50022746..6989140a 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php @@ -9,6 +9,9 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; interface ShortUrlTitleResolutionHelperInterface { /** + * @template T of TitleResolutionModelInterface + * @param T $data + * @return T * @throws InvalidUrlException */ public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface; diff --git a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php index 8af28706..1c834331 100644 --- a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php +++ b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php @@ -12,5 +12,5 @@ interface TitleResolutionModelInterface public function doValidateUrl(): bool; - public function withResolvedTitle(string $title): self; + public function withResolvedTitle(string $title): static; } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index d63482ec..f2e156f4 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -85,7 +85,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface ); } - public function withResolvedTitle(string $title): self + public function withResolvedTitle(string $title): static { return new self( longUrl: $this->longUrl, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 32451d2e..fb0f9bb0 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -76,7 +76,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface ); } - public function withResolvedTitle(string $title): self + public function withResolvedTitle(string $title): static { return new self( longUrlPropWasProvided: $this->longUrlPropWasProvided, diff --git a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php new file mode 100644 index 00000000..1e9d9824 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php @@ -0,0 +1,57 @@ + 'Provided value is not an array.', + self::INVALID_DEVICE => 'You have provided at least one invalid device identifier.', + self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.', + ]; + + public function __construct(private readonly ValidatorChain $longUrlValidators) + { + parent::__construct(); + } + + public function isValid(mixed $value): bool + { + if (! is_array($value)) { + $this->error(self::NOT_ARRAY); + return false; + } + + $validValues = enumValues(DeviceType::class); + $keys = array_keys($value); + if (! every($keys, static fn ($key) => contains($validValues, $key))) { + $this->error(self::INVALID_DEVICE); + return false; + } + + $longUrls = array_values($value); + $result = every($longUrls, $this->longUrlValidators->isValid(...)); + if (! $result) { + $this->error(self::INVALID_LONG_URL); + } + + return $result; + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 72708250..7b01841b 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -10,16 +10,9 @@ use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Config\EnvVars; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function array_keys; -use function array_values; -use function Functional\contains; -use function Functional\every; -use function is_array; use function is_string; -use function Shlinkio\Shlink\Core\enumValues; use function str_replace; use function substr; use function trim; @@ -64,37 +57,20 @@ class ShortUrlInputFilter extends InputFilter private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void { - $notEmptyValidator = new Validator\NotEmpty([ + $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::OBJECT, Validator\NotEmpty::SPACE, Validator\NotEmpty::NULL, Validator\NotEmpty::EMPTY_ARRAY, Validator\NotEmpty::BOOLEAN, Validator\NotEmpty::STRING, - ]); - - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); - $longUrlInput->getValidatorChain()->attach($notEmptyValidator); + ])); $this->add($longUrlInput); $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); - $deviceLongUrlsInput->getValidatorChain()->attach( // TODO Extract callback to own validator - new Validator\Callback(function (mixed $value) use ($notEmptyValidator): bool { - if (! is_array($value)) { - // TODO Set proper error: Not array - return false; - } - - $validValues = enumValues(DeviceType::class); - $keys = array_keys($value); - if (! every($keys, static fn ($key) => contains($validValues, $key))) { - // TODO Set proper error: Provided invalid device type - return false; - } - - $longUrls = array_values($value); - return every($longUrls, $notEmptyValidator->isValid(...)); - }), + $deviceLongUrlsInput->getValidatorChain()->attach( + new DeviceLongUrlsValidator($longUrlInput->getValidatorChain()), ); $this->add($deviceLongUrlsInput); diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 971ef932..db6721d5 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -49,7 +49,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt /** * @param string[] $tags - * @return Collection|Tag[] + * @return Collection */ public function resolveTags(array $tags): Collections\Collection { diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index a71f2ccc..b5228214 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -14,7 +14,7 @@ interface ShortUrlRelationResolverInterface /** * @param string[] $tags - * @return Collection|Tag[] + * @return Collection */ public function resolveTags(array $tags): Collection; } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index f25ff8a1..2048aba9 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -20,7 +20,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac /** * @param string[] $tags - * @return Collection|Tag[] + * @return Collection */ public function resolveTags(array $tags): Collections\Collection { diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index 163989d8..95561fc5 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -34,7 +34,6 @@ class ShortUrlService implements ShortUrlServiceInterface ?ApiKey $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { - /** @var ShortUrlEdition $shortUrlEdit */ $shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit); } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 4236189c..27e96262 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -31,22 +31,21 @@ class UrlShortener implements UrlShortenerInterface * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function shorten(ShortUrlCreation $meta): ShortUrl + public function shorten(ShortUrlCreation $creation): ShortUrl { // First, check if a short URL exists for all provided params - $existingShortUrl = $this->findExistingShortUrlIfExists($meta); + $existingShortUrl = $this->findExistingShortUrlIfExists($creation); if ($existingShortUrl !== null) { return $existingShortUrl; } - /** @var \Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation $meta */ - $meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta); + $creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation); /** @var ShortUrl $newShortUrl */ - $newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { - $shortUrl = ShortUrl::create($meta, $this->relationResolver); + $newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl { + $shortUrl = ShortUrl::create($creation, $this->relationResolver); - $this->verifyShortCodeUniqueness($meta, $shortUrl); + $this->verifyShortCodeUniqueness($creation, $shortUrl); $this->em->persist($shortUrl); return $shortUrl; diff --git a/module/Core/src/ShortUrl/UrlShortenerInterface.php b/module/Core/src/ShortUrl/UrlShortenerInterface.php index c15b7ebf..d7ae45f0 100644 --- a/module/Core/src/ShortUrl/UrlShortenerInterface.php +++ b/module/Core/src/ShortUrl/UrlShortenerInterface.php @@ -15,5 +15,5 @@ interface UrlShortenerInterface * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function shorten(ShortUrlCreation $meta): ShortUrl; + public function shorten(ShortUrlCreation $creation): ShortUrl; } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index beb9e0f9..297cdb45 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -21,7 +21,7 @@ class ApiKey extends AbstractEntity private string $key; private ?Chronos $expirationDate = null; private bool $enabled; - /** @var Collection|ApiKeyRole[] */ + /** @var Collection */ private Collection $roles; private ?string $name = null; From fdadf3ba07ccb01f980c017d393043c6e6451105 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Jan 2023 22:37:19 +0100 Subject: [PATCH 33/59] Created unit test for DeviceLongUrlsValidator --- .../DeviceLongUrlsValidatorTest.php | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php diff --git a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php new file mode 100644 index 00000000..42ad720b --- /dev/null +++ b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php @@ -0,0 +1,75 @@ +attach(new NotEmpty()); + + $this->validator = new DeviceLongUrlsValidator($longUrlValidators); + } + + /** + * @test + * @dataProvider provideNonArrayValues + */ + public function nonArrayValuesAreNotValid(mixed $invalidValue): void + { + self::assertFalse($this->validator->isValid($invalidValue)); + self::assertEquals(['NOT_ARRAY' => 'Provided value is not an array.'], $this->validator->getMessages()); + } + + public function provideNonArrayValues(): iterable + { + yield 'int' => [0]; + yield 'float' => [100.45]; + yield 'string' => ['foo']; + yield 'boolean' => [true]; + yield 'object' => [new stdClass()]; + yield 'null' => [null]; + } + + /** @test */ + public function unrecognizedKeysAreNotValid(): void + { + self::assertFalse($this->validator->isValid(['foo' => 'bar'])); + self::assertEquals( + ['INVALID_DEVICE' => 'You have provided at least one invalid device identifier.'], + $this->validator->getMessages(), + ); + } + + /** @test */ + public function everyUrlMustMatchLongUrlValidator(): void + { + self::assertFalse($this->validator->isValid([DeviceType::ANDROID->value => ''])); + self::assertEquals( + ['INVALID_LONG_URL' => 'At least one of the long URLs are invalid.'], + $this->validator->getMessages(), + ); + } + + /** @test */ + public function validValuesResultInValidResult(): void + { + self::assertTrue($this->validator->isValid([ + DeviceType::ANDROID->value => 'foo', + DeviceType::IOS->value => 'bar', + DeviceType::DESKTOP->value => 'baz', + ])); + } +} From a93edf158ea621627f837b4583e51b9cd45aabd6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Jan 2023 13:08:21 +0100 Subject: [PATCH 34/59] Added logic to persist device long URLs while creating/editing a short URL --- ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 5 + module/Core/src/Domain/DomainService.php | 2 +- module/Core/src/Domain/Entity/Domain.php | 4 +- module/Core/src/Domain/Model/DomainItem.php | 2 +- .../src/ShortUrl/Entity/DeviceLongUrl.php | 7 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 101 +++++++++++------- .../ShortUrl/Helper/ShortUrlStringifier.php | 4 +- .../src/ShortUrl/Model/ShortUrlEdition.php | 1 + .../src/ShortUrl/Model/ShortUrlIdentifier.php | 2 +- .../SimpleShortUrlRelationResolver.php | 3 +- module/Core/src/ShortUrl/UrlShortener.php | 2 +- module/Core/src/Visit/VisitsTracker.php | 9 +- .../Repository/DomainRepositoryTest.php | 2 +- .../Repository/ShortUrlRepositoryTest.php | 10 +- .../Tag/Repository/TagRepositoryTest.php | 2 +- .../Visit/Repository/VisitRepositoryTest.php | 4 +- ...ersistenceShortUrlRelationResolverTest.php | 2 +- .../SimpleShortUrlRelationResolverTest.php | 2 +- .../test/ShortUrl/ShortUrlServiceTest.php | 63 ++++++----- .../Rest/src/ApiKey/Model/RoleDefinition.php | 2 +- module/Rest/src/Entity/ApiKey.php | 7 +- .../ShortUrl/OverrideDomainMiddleware.php | 4 +- 22 files changed, 142 insertions(+), 98 deletions(-) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index b67996ae..13aa36f6 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -67,6 +67,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->fetchExtraLazy() ->build(); + $builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class) + ->mappedBy('shortUrl') + ->cascadePersist() + ->build(); + $builder->createManyToMany('tags', Tag\Entity\Tag::class) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 3ecc9f03..29afa110 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -51,7 +51,7 @@ class DomainService implements DomainServiceInterface $repo = $this->em->getRepository(Domain::class); $groups = group( $repo->findDomains($apiKey), - fn (Domain $domain) => $domain->getAuthority() === $this->defaultDomain ? 'default' : 'domains', + fn (Domain $domain) => $domain->authority() === $this->defaultDomain ? 'default' : 'domains', ); return [first($groups['default'] ?? []), $groups['domains'] ?? []]; diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index ab33ae17..b9b5c334 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -24,14 +24,14 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return new self($authority); } - public function getAuthority(): string + public function authority(): string { return $this->authority; } public function jsonSerialize(): string { - return $this->getAuthority(); + return $this->authority; } public function invalidShortUrlRedirect(): ?string diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 72ea3e1f..2a1c7fcf 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -20,7 +20,7 @@ final class DomainItem implements JsonSerializable public static function forNonDefaultDomain(Domain $domain): self { - return new self($domain->getAuthority(), $domain, false); + return new self($domain->authority(), $domain, false); } public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php index b3db666d..668741e8 100644 --- a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -10,17 +10,16 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; class DeviceLongUrl extends AbstractEntity { - private ShortUrl $shortUrl; // @phpstan-ignore-line - private function __construct( + private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine public readonly DeviceType $deviceType, private string $longUrl, ) { } - public static function fromPair(DeviceLongUrlPair $pair): self + public static function fromShortUrlAndPair(ShortUrl $shortUrl, DeviceLongUrlPair $pair): self { - return new self($pair->deviceType, $pair->longUrl); + return new self($shortUrl, $pair->deviceType, $pair->longUrl); } public function longUrl(): string diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 51bd09ea..644c52eb 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,6 +12,7 @@ use Doctrine\Common\Collections\Selectable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -24,6 +25,7 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function count; +use function Functional\map; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate; @@ -35,6 +37,8 @@ class ShortUrl extends AbstractEntity private Chronos $dateCreated; /** @var Collection */ private Collection $visits; + /** @var Collection */ + private Collection $deviceLongUrls; /** @var Collection */ private Collection $tags; private ?Chronos $validSince = null; @@ -81,6 +85,10 @@ class ShortUrl extends AbstractEntity $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); + $instance->deviceLongUrls = new ArrayCollection(map( + $creation->deviceLongUrls, + fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair), + )); $instance->tags = $relationResolver->resolveTags($creation->tags); $instance->validSince = $creation->validSince; $instance->validUntil = $creation->validUntil; @@ -126,6 +134,53 @@ class ShortUrl extends AbstractEntity return $instance; } + public function update( + ShortUrlEdition $shortUrlEdit, + ?ShortUrlRelationResolverInterface $relationResolver = null, + ): void { + if ($shortUrlEdit->validSinceWasProvided()) { + $this->validSince = $shortUrlEdit->validSince; + } + if ($shortUrlEdit->validUntilWasProvided()) { + $this->validUntil = $shortUrlEdit->validUntil; + } + if ($shortUrlEdit->maxVisitsWasProvided()) { + $this->maxVisits = $shortUrlEdit->maxVisits; + } + if ($shortUrlEdit->longUrlWasProvided()) { + $this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl; + } + if ($shortUrlEdit->tagsWereProvided()) { + $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); + $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags); + } + if ($shortUrlEdit->crawlableWasProvided()) { + $this->crawlable = $shortUrlEdit->crawlable; + } + if ( + $this->title === null + || $shortUrlEdit->titleWasProvided() + || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) + ) { + $this->title = $shortUrlEdit->title; + $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); + } + if ($shortUrlEdit->forwardQueryWasProvided()) { + $this->forwardQuery = $shortUrlEdit->forwardQuery; + } + foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { + $deviceLongUrl = $this->deviceLongUrls->findFirst( + fn ($_, DeviceLongUrl $d) => $d->deviceType === $deviceLongUrlPair->deviceType, + ); + + if ($deviceLongUrl !== null) { + $deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl); + } else { + $this->deviceLongUrls->add(DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair)); + } + } + } + public function getLongUrl(): string { return $this->longUrl; @@ -224,42 +279,6 @@ class ShortUrl extends AbstractEntity return $this->forwardQuery; } - public function update( - ShortUrlEdition $shortUrlEdit, - ?ShortUrlRelationResolverInterface $relationResolver = null, - ): void { - if ($shortUrlEdit->validSinceWasProvided()) { - $this->validSince = $shortUrlEdit->validSince; - } - if ($shortUrlEdit->validUntilWasProvided()) { - $this->validUntil = $shortUrlEdit->validUntil; - } - if ($shortUrlEdit->maxVisitsWasProvided()) { - $this->maxVisits = $shortUrlEdit->maxVisits; - } - if ($shortUrlEdit->longUrlWasProvided()) { - $this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl; - } - if ($shortUrlEdit->tagsWereProvided()) { - $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); - $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags); - } - if ($shortUrlEdit->crawlableWasProvided()) { - $this->crawlable = $shortUrlEdit->crawlable; - } - if ( - $this->title === null - || $shortUrlEdit->titleWasProvided() - || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) - ) { - $this->title = $shortUrlEdit->title; - $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); - } - if ($shortUrlEdit->forwardQueryWasProvided()) { - $this->forwardQuery = $shortUrlEdit->forwardQuery; - } - } - /** * @throws ShortCodeCannotBeRegeneratedException */ @@ -298,4 +317,14 @@ class ShortUrl extends AbstractEntity return true; } + + public function deviceLongUrls(): array + { + $data = []; + foreach ($this->deviceLongUrls as $deviceUrl) { + $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); + } + + return $data; + } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 719f82b8..795b2490 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -11,7 +11,7 @@ use function sprintf; class ShortUrlStringifier implements ShortUrlStringifierInterface { - public function __construct(private array $domainConfig, private string $basePath = '') + public function __construct(private readonly array $domainConfig, private readonly string $basePath = '') { } @@ -28,6 +28,6 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface private function resolveDomain(ShortUrl $shortUrl): string { - return $shortUrl->getDomain()?->getAuthority() ?? $this->domainConfig['hostname'] ?? ''; + return $shortUrl->getDomain()?->authority() ?? $this->domainConfig['hostname'] ?? ''; } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index fb0f9bb0..25645437 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -18,6 +18,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface { /** * @param string[] $tags + * @param DeviceLongUrlPair[] $deviceLongUrls */ private function __construct( private readonly bool $longUrlPropWasProvided = false, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index d7b49c68..fc930de5 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -45,7 +45,7 @@ final class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); - $domainAuthority = $domain?->getAuthority(); + $domainAuthority = $domain?->authority(); return new self($shortUrl->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 2048aba9..609a300c 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Doctrine\Common\Collections; -use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Tag\Entity\Tag; @@ -20,7 +19,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac /** * @param string[] $tags - * @return Collection + * @return Collections\Collection */ public function resolveTags(array $tags): Collections\Collection { diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 27e96262..4720809e 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -76,7 +76,7 @@ class UrlShortener implements UrlShortenerInterface if (! $couldBeMadeUnique) { $domain = $shortUrlToBeCreated->getDomain(); - $domainAuthority = $domain?->getAuthority(); + $domainAuthority = $domain?->authority(); throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index f97fc618..dd5fff91 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -15,9 +15,9 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; class VisitsTracker implements VisitsTrackerInterface { public function __construct( - private ORM\EntityManagerInterface $em, - private EventDispatcherInterface $eventDispatcher, - private TrackingOptions $options, + private readonly ORM\EntityManagerInterface $em, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly TrackingOptions $options, ) { } @@ -62,6 +62,9 @@ class VisitsTracker implements VisitsTrackerInterface $this->trackVisit($createVisit, $visitor); } + /** + * @param callable(Visitor $visitor): Visit $createVisit + */ private function trackVisit(callable $createVisit, Visitor $visitor): void { if ($this->options->disableTracking) { diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index c96d70ff..0db35974 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -131,7 +131,7 @@ class DomainRepositoryTest extends DatabaseTestCase { return ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo'], + ['domain' => $domain->authority(), 'apiKey' => $apiKey, 'longUrl' => 'foo'], ), new class ($domain) implements ShortUrlRelationResolverInterface { public function __construct(private Domain $domain) diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index ed500349..01c6c326 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -270,7 +270,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'validSince' => $start, 'apiKey' => $apiKey, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority(), 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], ]), $this->relationResolver); @@ -313,7 +313,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority(), 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], ])), @@ -322,7 +322,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority(), 'apiKey' => $rightDomainApiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -332,7 +332,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority(), 'apiKey' => $apiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -341,7 +341,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertNull( $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority(), 'apiKey' => $wrongDomainApiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 4365731a..24a8f516 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -249,7 +249,7 @@ class TagRepositoryTest extends DatabaseTestCase $shortUrl2 = ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->getAuthority(), 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], + ['domain' => $domain->authority(), 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], ), $this->relationResolver, ); diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index df4c5334..01c8e590 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -265,7 +265,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::create( ShortUrlCreation::fromRawData( - ['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => 'longUrl'], + ['apiKey' => $apiKey1, 'domain' => $domain->authority(), 'longUrl' => 'longUrl'], ), $this->relationResolver, ); @@ -280,7 +280,7 @@ class VisitRepositoryTest extends DatabaseTestCase $shortUrl3 = ShortUrl::create( ShortUrlCreation::fromRawData( - ['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => 'longUrl'], + ['apiKey' => $apiKey2, 'domain' => $domain->authority(), 'longUrl' => 'longUrl'], ), $this->relationResolver, ); diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 8734b95c..fed61862 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -52,7 +52,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase self::assertSame($result, $foundDomain); } self::assertInstanceOf(Domain::class, $result); - self::assertEquals($authority, $result->getAuthority()); + self::assertEquals($authority, $result->authority()); } public function provideFoundDomains(): iterable diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index f0cc7023..f1925c68 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -30,7 +30,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase self::assertNull($result); } else { self::assertInstanceOf(Domain::class, $result); - self::assertEquals($domain, $result->getAuthority()); + self::assertEquals($domain, $result->authority()); } } diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 96e0c9f5..903c3d3d 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -7,7 +7,9 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -23,21 +25,20 @@ class ShortUrlServiceTest extends TestCase use ApiKeyHelpersTrait; private ShortUrlService $service; - private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlResolverInterface $urlResolver; private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); - $this->em->method('persist')->willReturn(null); - $this->em->method('flush')->willReturn(null); + $em = $this->createMock(EntityManagerInterface::class); + $em->method('persist')->willReturn(null); + $em->method('flush')->willReturn(null); $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); $this->service = new ShortUrlService( - $this->em, + $em, $this->urlResolver, $this->titleResolutionHelper, new SimpleShortUrlRelationResolver(), @@ -49,7 +50,7 @@ class ShortUrlServiceTest extends TestCase * @dataProvider provideShortUrlEdits */ public function updateShortUrlUpdatesProvidedData( - int $expectedValidateCalls, + InvocationOrder $expectedValidateCalls, ShortUrlEdition $shortUrlEdit, ?ApiKey $apiKey, ): void { @@ -61,7 +62,7 @@ class ShortUrlServiceTest extends TestCase $apiKey, )->willReturn($shortUrl); - $this->titleResolutionHelper->expects($this->exactly($expectedValidateCalls)) + $this->titleResolutionHelper->expects($expectedValidateCalls) ->method('processTitleAndValidateUrl') ->with($shortUrlEdit) ->willReturn($shortUrlEdit); @@ -72,34 +73,44 @@ class ShortUrlServiceTest extends TestCase $apiKey, ); + $resolveDeviceLongUrls = function () use ($shortUrlEdit): array { + $result = []; + foreach ($shortUrlEdit->deviceLongUrls ?? [] as $longUrl) { + $result[$longUrl->deviceType->value] = $longUrl->longUrl; + } + + return $result; + }; + self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince()); self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil()); self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits()); self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl()); + self::assertEquals($resolveDeviceLongUrls(), $shortUrl->deviceLongUrls()); } public function provideShortUrlEdits(): iterable { - yield 'no long URL' => [0, ShortUrlEdition::fromRawData( - [ - 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), - 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), - 'maxVisits' => 5, + yield 'no long URL' => [$this->never(), ShortUrlEdition::fromRawData([ + 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), + 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), + 'maxVisits' => 5, + ]), null]; + yield 'long URL and API key' => [$this->once(), ShortUrlEdition::fromRawData([ + 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), + 'maxVisits' => 10, + 'longUrl' => 'modifiedLongUrl', + ]), ApiKey::create()]; + yield 'long URL with validation' => [$this->once(), ShortUrlEdition::fromRawData([ + 'longUrl' => 'modifiedLongUrl', + 'validateUrl' => true, + ]), null]; + yield 'device redirects' => [$this->never(), ShortUrlEdition::fromRawData([ + 'deviceLongUrls' => [ + DeviceType::IOS->value => 'iosLongUrl', + DeviceType::ANDROID->value => 'androidLongUrl', ], - ), null]; - yield 'long URL' => [1, ShortUrlEdition::fromRawData( - [ - 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), - 'maxVisits' => 10, - 'longUrl' => 'modifiedLongUrl', - ], - ), ApiKey::create()]; - yield 'long URL with validation' => [1, ShortUrlEdition::fromRawData( - [ - 'longUrl' => 'modifiedLongUrl', - 'validateUrl' => true, - ], - ), null]; + ]), null]; } } diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 6132772a..bc868f41 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -22,7 +22,7 @@ final class RoleDefinition { return new self( Role::DOMAIN_SPECIFIC, - ['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()], + ['domain_id' => $domain->getId(), 'authority' => $domain->authority()], ); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 297cdb45..57fecdd0 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -147,12 +147,9 @@ class ApiKey extends AbstractEntity $meta = $roleDefinition->meta; if ($this->hasRole($role)) { - /** @var ApiKeyRole $apiKeyRole */ - $apiKeyRole = $this->roles->get($role->value); - $apiKeyRole->updateMeta($meta); + $this->roles->get($role->value)?->updateMeta($meta); } else { - $apiKeyRole = new ApiKeyRole($roleDefinition->role, $roleDefinition->meta, $this); - $this->roles[$role->value] = $apiKeyRole; + $this->roles->set($role->value, new ApiKeyRole($role, $meta, $this)); } } } diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index f4d01e97..ab92c77a 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -34,11 +34,11 @@ class OverrideDomainMiddleware implements MiddlewareInterface if ($requestMethod === RequestMethodInterface::METHOD_POST) { /** @var array $payload */ $payload = $request->getParsedBody(); - $payload[ShortUrlInputFilter::DOMAIN] = $domain->getAuthority(); + $payload[ShortUrlInputFilter::DOMAIN] = $domain->authority(); return $handler->handle($request->withParsedBody($payload)); } - return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->getAuthority())); + return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->authority())); } } From d8add9291f0184c29ec6c1b7e082915e4dafce6a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Jan 2023 13:12:17 +0100 Subject: [PATCH 35/59] Removed public readonly prop from entity, as it can cause errors when a proxy is generated --- module/Core/src/ShortUrl/Entity/DeviceLongUrl.php | 7 ++++++- module/Core/src/ShortUrl/Entity/ShortUrl.php | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php index 668741e8..315f7f38 100644 --- a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -12,7 +12,7 @@ class DeviceLongUrl extends AbstractEntity { private function __construct( private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine - public readonly DeviceType $deviceType, + private readonly DeviceType $deviceType, private string $longUrl, ) { } @@ -27,6 +27,11 @@ class DeviceLongUrl extends AbstractEntity return $this->longUrl; } + public function deviceType(): DeviceType + { + return $this->deviceType; + } + public function updateLongUrl(string $longUrl): void { $this->longUrl = $longUrl; diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 644c52eb..3a1b7329 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -170,7 +170,7 @@ class ShortUrl extends AbstractEntity } foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { $deviceLongUrl = $this->deviceLongUrls->findFirst( - fn ($_, DeviceLongUrl $d) => $d->deviceType === $deviceLongUrlPair->deviceType, + fn ($_, DeviceLongUrl $d) => $d->deviceType() === $deviceLongUrlPair->deviceType, ); if ($deviceLongUrl !== null) { @@ -322,7 +322,7 @@ class ShortUrl extends AbstractEntity { $data = []; foreach ($this->deviceLongUrls as $deviceUrl) { - $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); + $data[$deviceUrl->deviceType()->value] = $deviceUrl->longUrl(); } return $data; From c1b7c6ba6c4bfaf8cf36c1e0d088501245179c73 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Jan 2023 23:41:14 +0100 Subject: [PATCH 36/59] Updated to shlink-common with support for proxies for entities with public readonly props --- composer.json | 2 +- module/Core/src/Domain/DomainService.php | 2 +- module/Core/src/Domain/Entity/Domain.php | 7 +------ module/Core/src/Domain/Model/DomainItem.php | 2 +- .../Core/src/ShortUrl/Helper/ShortUrlStringifier.php | 2 +- module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php | 2 +- module/Core/src/ShortUrl/UrlShortener.php | 2 +- .../test-db/Domain/Repository/DomainRepositoryTest.php | 2 +- .../ShortUrl/Repository/ShortUrlRepositoryTest.php | 10 +++++----- .../Core/test-db/Tag/Repository/TagRepositoryTest.php | 2 +- .../test-db/Visit/Repository/VisitRepositoryTest.php | 4 ++-- .../PersistenceShortUrlRelationResolverTest.php | 2 +- .../Resolver/SimpleShortUrlRelationResolverTest.php | 2 +- module/Rest/src/ApiKey/Model/RoleDefinition.php | 2 +- .../Middleware/ShortUrl/OverrideDomainMiddleware.php | 4 ++-- 15 files changed, 21 insertions(+), 26 deletions(-) diff --git a/composer.json b/composer.json index 1ad2e7ab..d7741611 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.5", - "shlinkio/shlink-common": "^5.2", + "shlinkio/shlink-common": "dev-main#61d26e7 as 5.3", "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 29afa110..703f77fd 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -51,7 +51,7 @@ class DomainService implements DomainServiceInterface $repo = $this->em->getRepository(Domain::class); $groups = group( $repo->findDomains($apiKey), - fn (Domain $domain) => $domain->authority() === $this->defaultDomain ? 'default' : 'domains', + fn (Domain $domain) => $domain->authority === $this->defaultDomain ? 'default' : 'domains', ); return [first($groups['default'] ?? []), $groups['domains'] ?? []]; diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index b9b5c334..4e6ea865 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -15,7 +15,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec private ?string $regular404Redirect = null; private ?string $invalidShortUrlRedirect = null; - private function __construct(private string $authority) + private function __construct(public readonly string $authority) { } @@ -24,11 +24,6 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return new self($authority); } - public function authority(): string - { - return $this->authority; - } - public function jsonSerialize(): string { return $this->authority; diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 2a1c7fcf..53f2b6f7 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -20,7 +20,7 @@ final class DomainItem implements JsonSerializable public static function forNonDefaultDomain(Domain $domain): self { - return new self($domain->authority(), $domain, false); + return new self($domain->authority, $domain, false); } public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 795b2490..9d21cb58 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -28,6 +28,6 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface private function resolveDomain(ShortUrl $shortUrl): string { - return $shortUrl->getDomain()?->authority() ?? $this->domainConfig['hostname'] ?? ''; + return $shortUrl->getDomain()?->authority ?? $this->domainConfig['hostname'] ?? ''; } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index fc930de5..bb3b4af6 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -45,7 +45,7 @@ final class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); - $domainAuthority = $domain?->authority(); + $domainAuthority = $domain?->authority; return new self($shortUrl->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 4720809e..7477052f 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -76,7 +76,7 @@ class UrlShortener implements UrlShortenerInterface if (! $couldBeMadeUnique) { $domain = $shortUrlToBeCreated->getDomain(); - $domainAuthority = $domain?->authority(); + $domainAuthority = $domain?->authority; throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 0db35974..2b005947 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -131,7 +131,7 @@ class DomainRepositoryTest extends DatabaseTestCase { return ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->authority(), 'apiKey' => $apiKey, 'longUrl' => 'foo'], + ['domain' => $domain->authority, 'apiKey' => $apiKey, 'longUrl' => 'foo'], ), new class ($domain) implements ShortUrlRelationResolverInterface { public function __construct(private Domain $domain) diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 01c6c326..99e3add9 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -270,7 +270,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'validSince' => $start, 'apiKey' => $apiKey, - 'domain' => $rightDomain->authority(), + 'domain' => $rightDomain->authority, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], ]), $this->relationResolver); @@ -313,7 +313,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->authority(), + 'domain' => $rightDomain->authority, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], ])), @@ -322,7 +322,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->authority(), + 'domain' => $rightDomain->authority, 'apiKey' => $rightDomainApiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -332,7 +332,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->authority(), + 'domain' => $rightDomain->authority, 'apiKey' => $apiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -341,7 +341,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertNull( $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->authority(), + 'domain' => $rightDomain->authority, 'apiKey' => $wrongDomainApiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 24a8f516..97873b20 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -249,7 +249,7 @@ class TagRepositoryTest extends DatabaseTestCase $shortUrl2 = ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->authority(), 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], + ['domain' => $domain->authority, 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], ), $this->relationResolver, ); diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 01c8e590..f1fed415 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -265,7 +265,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::create( ShortUrlCreation::fromRawData( - ['apiKey' => $apiKey1, 'domain' => $domain->authority(), 'longUrl' => 'longUrl'], + ['apiKey' => $apiKey1, 'domain' => $domain->authority, 'longUrl' => 'longUrl'], ), $this->relationResolver, ); @@ -280,7 +280,7 @@ class VisitRepositoryTest extends DatabaseTestCase $shortUrl3 = ShortUrl::create( ShortUrlCreation::fromRawData( - ['apiKey' => $apiKey2, 'domain' => $domain->authority(), 'longUrl' => 'longUrl'], + ['apiKey' => $apiKey2, 'domain' => $domain->authority, 'longUrl' => 'longUrl'], ), $this->relationResolver, ); diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index fed61862..fedfd96f 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -52,7 +52,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase self::assertSame($result, $foundDomain); } self::assertInstanceOf(Domain::class, $result); - self::assertEquals($authority, $result->authority()); + self::assertEquals($authority, $result->authority); } public function provideFoundDomains(): iterable diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index f1925c68..443710bb 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -30,7 +30,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase self::assertNull($result); } else { self::assertInstanceOf(Domain::class, $result); - self::assertEquals($domain, $result->authority()); + self::assertEquals($domain, $result->authority); } } diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index bc868f41..403e6214 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -22,7 +22,7 @@ final class RoleDefinition { return new self( Role::DOMAIN_SPECIFIC, - ['domain_id' => $domain->getId(), 'authority' => $domain->authority()], + ['domain_id' => $domain->getId(), 'authority' => $domain->authority], ); } } diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index ab92c77a..8a88e340 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -34,11 +34,11 @@ class OverrideDomainMiddleware implements MiddlewareInterface if ($requestMethod === RequestMethodInterface::METHOD_POST) { /** @var array $payload */ $payload = $request->getParsedBody(); - $payload[ShortUrlInputFilter::DOMAIN] = $domain->authority(); + $payload[ShortUrlInputFilter::DOMAIN] = $domain->authority; return $handler->handle($request->withParsedBody($payload)); } - return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->authority())); + return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->authority)); } } From 237fb95b4b732a097ec26d6c566f9edd802e548d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Jan 2023 10:37:12 +0100 Subject: [PATCH 37/59] Update ShortUrlRedirectionBuilder to accept a request object instead of a raw query array --- module/Core/src/Action/RedirectAction.php | 6 +++--- .../ShortUrl/Helper/ShortUrlRedirectionBuilder.php | 11 ++++++++--- .../Helper/ShortUrlRedirectionBuilderInterface.php | 7 ++++++- .../Middleware/ExtraPathRedirectMiddleware.php | 3 +-- .../Helper/ShortUrlRedirectionBuilderTest.php | 7 ++++++- .../Middleware/ExtraPathRedirectMiddlewareTest.php | 2 +- module/Rest/test-api/Fixtures/ShortUrlsFixture.php | 5 +++++ 7 files changed, 30 insertions(+), 11 deletions(-) diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 725e402d..942cf550 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -18,15 +18,15 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa public function __construct( ShortUrlResolverInterface $urlResolver, RequestTrackerInterface $requestTracker, - private ShortUrlRedirectionBuilderInterface $redirectionBuilder, - private RedirectResponseHelperInterface $redirectResponseHelper, + private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder, + private readonly RedirectResponseHelperInterface $redirectResponseHelper, ) { parent::__construct($urlResolver, $requestTracker); } protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response { - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams()); + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index f003318d..4f457659 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use GuzzleHttp\Psr7\Query; use Laminas\Stdlib\ArrayUtils; use League\Uri\Uri; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -14,12 +15,16 @@ use function sprintf; class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface { - public function __construct(private TrackingOptions $trackingOptions) + public function __construct(private readonly TrackingOptions $trackingOptions) { } - public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string - { + public function buildShortUrlRedirect( + ShortUrl $shortUrl, + ServerRequestInterface $request, + ?string $extraPath = null, + ): string { + $currentQuery = $request->getQueryParams(); $uri = Uri::createFromString($shortUrl->getLongUrl()); $shouldForwardQuery = $shortUrl->forwardQuery(); diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php index 44bd9ccb..7f79e98a 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; interface ShortUrlRedirectionBuilderInterface { - public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string; + public function buildShortUrlRedirect( + ShortUrl $shortUrl, + ServerRequestInterface $request, + ?string $extraPath = null, + ): string; } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 66105779..c8f96bba 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -68,7 +68,6 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface int $shortCodeSegments = 1, ): ResponseInterface { $uri = $request->getUri(); - $query = $request->getQueryParams(); [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); @@ -76,7 +75,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); $this->requestTracker->trackIfApplicable($shortUrl, $request); - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index cb94a9f1..342ba1ff 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -34,7 +35,11 @@ class ShortUrlRedirectionBuilderTest extends TestCase 'longUrl' => 'https://domain.com/foo/bar?some=thing', 'forwardQuery' => $forwardQuery, ])); - $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + $result = $this->redirectionBuilder->buildShortUrlRedirect( + $shortUrl, + ServerRequestFactory::fromGlobals()->withQueryParams($query), + $extraPath, + ); self::assertEquals($expectedUrl, $result); } diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 696a47ab..c157403e 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -159,7 +159,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase ); $this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with( $shortUrl, - [], + $this->isInstanceOf(ServerRequestInterface::class), $expectedExtraPath, )->willReturn('the_built_long_url'); $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 9a876463..2d45a7bb 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -9,6 +9,7 @@ use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use ReflectionObject; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -48,6 +49,10 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + 'deviceLongUrls' => [ + DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', + DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', + ], 'tags' => ['foo', 'bar'], ]), $relationResolver), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); From b1b67c497ebe997edc34977e09e31d303ccf1d46 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Jan 2023 11:15:38 +0100 Subject: [PATCH 38/59] Add logic to dynamically resolve the long URL to redirect to based on requesting device --- composer.json | 5 +- config/test/constants.php | 7 ++ .../src/ShortUrl/Entity/DeviceLongUrl.php | 7 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 14 +++- .../Helper/ShortUrlRedirectionBuilder.php | 4 +- module/Core/test-api/Action/RedirectTest.php | 38 ++++++++++ .../Helper/ShortUrlRedirectionBuilderTest.php | 70 ++++++++++++------- 7 files changed, 109 insertions(+), 36 deletions(-) create mode 100644 module/Core/test-api/Action/RedirectTest.php diff --git a/composer.json b/composer.json index d7741611..6a279051 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.3", + "shlinkio/shlink-test-utils": "^3.4", "symfony/var-dumper": "^6.1", "veewee/composer-run-parallel": "^1.1" }, @@ -97,7 +97,8 @@ "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", "ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db", "ShlinkioTest\\Shlink\\Core\\": "module/Core/test", - "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db" + "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db", + "ShlinkioApiTest\\Shlink\\Core\\": "module/Core/test-api" }, "files": [ "config/test/constants.php" diff --git a/config/test/constants.php b/config/test/constants.php index c767abc9..bce232f3 100644 --- a/config/test/constants.php +++ b/config/test/constants.php @@ -6,3 +6,10 @@ namespace ShlinkioTest\Shlink; const API_TESTS_HOST = '127.0.0.1'; const API_TESTS_PORT = 9999; + +const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/109.0.5414.86 Mobile Safari/537.36'; +const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 ' + . '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15'; +const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like ' + . 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61'; diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php index 315f7f38..668741e8 100644 --- a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -12,7 +12,7 @@ class DeviceLongUrl extends AbstractEntity { private function __construct( private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine - private readonly DeviceType $deviceType, + public readonly DeviceType $deviceType, private string $longUrl, ) { } @@ -27,11 +27,6 @@ class DeviceLongUrl extends AbstractEntity return $this->longUrl; } - public function deviceType(): DeviceType - { - return $this->deviceType; - } - public function updateLongUrl(string $longUrl): void { $this->longUrl = $longUrl; diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 3a1b7329..9063bdd7 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,6 +12,7 @@ use Doctrine\Common\Collections\Selectable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -170,7 +171,7 @@ class ShortUrl extends AbstractEntity } foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { $deviceLongUrl = $this->deviceLongUrls->findFirst( - fn ($_, DeviceLongUrl $d) => $d->deviceType() === $deviceLongUrlPair->deviceType, + fn ($_, DeviceLongUrl $d) => $d->deviceType === $deviceLongUrlPair->deviceType, ); if ($deviceLongUrl !== null) { @@ -186,6 +187,15 @@ class ShortUrl extends AbstractEntity return $this->longUrl; } + public function longUrlForDevice(?DeviceType $deviceType): string + { + $deviceLongUrl = $this->deviceLongUrls->findFirst( + static fn ($_, DeviceLongUrl $longUrl) => $longUrl->deviceType === $deviceType, + ); + + return $deviceLongUrl?->longUrl() ?? $this->longUrl; + } + public function getShortCode(): string { return $this->shortCode; @@ -322,7 +332,7 @@ class ShortUrl extends AbstractEntity { $data = []; foreach ($this->deviceLongUrls as $deviceUrl) { - $data[$deviceUrl->deviceType()->value] = $deviceUrl->longUrl(); + $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); } return $data; diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index 4f457659..c322f195 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -8,6 +8,7 @@ use GuzzleHttp\Psr7\Query; use Laminas\Stdlib\ArrayUtils; use League\Uri\Uri; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -25,7 +26,8 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface ?string $extraPath = null, ): string { $currentQuery = $request->getQueryParams(); - $uri = Uri::createFromString($shortUrl->getLongUrl()); + $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); + $uri = Uri::createFromString($shortUrl->longUrlForDevice($device)); $shouldForwardQuery = $shortUrl->forwardQuery(); return $uri diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php new file mode 100644 index 00000000..73b6a1cc --- /dev/null +++ b/module/Core/test-api/Action/RedirectTest.php @@ -0,0 +1,38 @@ +callShortUrl('def456', $userAgent); + self::assertEquals($expectedRedirect, $response->getHeaderLine('Location')); + } + + public function provideUserAgents(): iterable + { + yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android']; + yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios']; + yield 'desktop' => [ + DESKTOP_USER_AGENT, + 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + ]; + yield 'unknown' => [ + null, + 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + ]; + } +} diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index 342ba1ff..341ff6bf 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -6,11 +6,17 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; +use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; +use const ShlinkioTest\Shlink\IOS_USER_AGENT; + class ShortUrlRedirectionBuilderTest extends TestCase { private ShortUrlRedirectionBuilder $redirectionBuilder; @@ -27,78 +33,92 @@ class ShortUrlRedirectionBuilderTest extends TestCase */ public function buildShortUrlRedirectBuildsExpectedUrl( string $expectedUrl, - array $query, + ServerRequestInterface $request, ?string $extraPath, ?bool $forwardQuery, ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://domain.com/foo/bar?some=thing', 'forwardQuery' => $forwardQuery, + 'deviceLongUrls' => [ + DeviceType::ANDROID->value => 'https://domain.com/android', + DeviceType::IOS->value => 'https://domain.com/ios', + ], ])); - $result = $this->redirectionBuilder->buildShortUrlRedirect( - $shortUrl, - ServerRequestFactory::fromGlobals()->withQueryParams($query), - $extraPath, - ); + $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); self::assertEquals($expectedUrl, $result); } public function provideData(): iterable { - yield ['https://domain.com/foo/bar?some=thing', [], null, true]; - yield ['https://domain.com/foo/bar?some=thing', [], null, null]; - yield ['https://domain.com/foo/bar?some=thing', [], null, false]; - yield ['https://domain.com/foo/bar?some=thing&else', ['else' => null], null, true]; - yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null, true]; - yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null, null]; - yield ['https://domain.com/foo/bar?some=thing', ['foo' => 'bar'], null, false]; - yield ['https://domain.com/foo/bar?some=thing&123=foo', ['123' => 'foo'], null, true]; - yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null, true]; - yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null, null]; - yield ['https://domain.com/foo/bar?some=thing', [456 => 'foo'], null, false]; + $request = static fn (array $query = []) => ServerRequestFactory::fromGlobals()->withQueryParams($query); + + yield ['https://domain.com/foo/bar?some=thing', $request(), null, true]; + yield ['https://domain.com/foo/bar?some=thing', $request(), null, null]; + yield ['https://domain.com/foo/bar?some=thing', $request(), null, false]; + yield ['https://domain.com/foo/bar?some=thing&else', $request(['else' => null]), null, true]; + yield ['https://domain.com/foo/bar?some=thing&foo=bar', $request(['foo' => 'bar']), null, true]; + yield ['https://domain.com/foo/bar?some=thing&foo=bar', $request(['foo' => 'bar']), null, null]; + yield ['https://domain.com/foo/bar?some=thing', $request(['foo' => 'bar']), null, false]; + yield ['https://domain.com/foo/bar?some=thing&123=foo', $request(['123' => 'foo']), null, true]; + yield ['https://domain.com/foo/bar?some=thing&456=foo', $request([456 => 'foo']), null, true]; + yield ['https://domain.com/foo/bar?some=thing&456=foo', $request([456 => 'foo']), null, null]; + yield ['https://domain.com/foo/bar?some=thing', $request([456 => 'foo']), null, false]; yield [ 'https://domain.com/foo/bar?some=overwritten&foo=bar', - ['foo' => 'bar', 'some' => 'overwritten'], + $request(['foo' => 'bar', 'some' => 'overwritten']), null, true, ]; yield [ 'https://domain.com/foo/bar?some=overwritten', - ['foobar' => 'notrack', 'some' => 'overwritten'], + $request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'), null, true, ]; yield [ 'https://domain.com/foo/bar?some=overwritten', - ['foobar' => 'notrack', 'some' => 'overwritten'], + $request(['foobar' => 'notrack', 'some' => 'overwritten']), null, null, ]; yield [ 'https://domain.com/foo/bar?some=thing', - ['foobar' => 'notrack', 'some' => 'overwritten'], + $request(['foobar' => 'notrack', 'some' => 'overwritten']), null, false, ]; - yield ['https://domain.com/foo/bar/something/else-baz?some=thing', [], '/something/else-baz', true]; + yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', - ['hello' => 'world'], + $request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT), '/something/else-baz', true, ]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', - ['hello' => 'world'], + $request(['hello' => 'world']), '/something/else-baz', null, ]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing', - ['hello' => 'world'], + $request(['hello' => 'world']), '/something/else-baz', false, ]; + yield [ + 'https://domain.com/android/something', + $request(['foo' => 'bar'])->withHeader('User-Agent', ANDROID_USER_AGENT), + '/something', + false, + ]; + yield [ + 'https://domain.com/ios?foo=bar', + $request(['foo' => 'bar'])->withHeader('User-Agent', IOS_USER_AGENT), + null, + null, + ]; } } From 48bd97fe418f3eaffa700738c0111352ad687a54 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Jan 2023 12:05:54 +0100 Subject: [PATCH 39/59] Return deviceLongUrls as part of the short URL data and document API changes --- docs/swagger/definitions/DeviceLongUrls.json | 20 +++ .../definitions/DeviceLongUrlsResp.json | 7 + docs/swagger/definitions/ShortUrl.json | 4 + docs/swagger/definitions/ShortUrlEdition.json | 3 + docs/swagger/paths/v1_short-urls.json | 20 +++ docs/swagger/paths/v1_short-urls_shorten.json | 6 + .../paths/v1_short-urls_{shortCode}.json | 10 ++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 4 +- .../Transformer/ShortUrlDataTransformer.php | 1 + .../PublishingUpdatesGeneratorTest.php | 2 + .../test/ShortUrl/ShortUrlServiceTest.php | 5 +- .../test-api/Action/ListShortUrlsTest.php | 142 ++++++++++-------- 12 files changed, 159 insertions(+), 65 deletions(-) create mode 100644 docs/swagger/definitions/DeviceLongUrls.json create mode 100644 docs/swagger/definitions/DeviceLongUrlsResp.json diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json new file mode 100644 index 00000000..25e7f322 --- /dev/null +++ b/docs/swagger/definitions/DeviceLongUrls.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "android": { + "description": "The long URL to redirect to when the short URL is visited from a device running Android", + "type": "string", + "nullable": true + }, + "ios": { + "description": "The long URL to redirect to when the short URL is visited from a device running iOS", + "type": "string", + "nullable": true + }, + "desktop": { + "description": "The long URL to redirect to when the short URL is visited from a desktop browser", + "type": "string", + "nullable": true + } + } +} diff --git a/docs/swagger/definitions/DeviceLongUrlsResp.json b/docs/swagger/definitions/DeviceLongUrlsResp.json new file mode 100644 index 00000000..64a56c01 --- /dev/null +++ b/docs/swagger/definitions/DeviceLongUrlsResp.json @@ -0,0 +1,7 @@ +{ + "type": "object", + "required": ["android", "ios", "desktop"], + "allOf": [{ + "$ref": "./DeviceLongUrls.json" + }] +} diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 4d5d9f2d..4060e2f2 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -4,6 +4,7 @@ "shortCode", "shortUrl", "longUrl", + "deviceLongUrls", "dateCreated", "visitsCount", "visitsSummary", @@ -27,6 +28,9 @@ "type": "string", "description": "The original long URL." }, + "deviceLongUrls": { + "$ref": "./DeviceLongUrlsResp.json" + }, "dateCreated": { "type": "string", "format": "date-time", diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index 94ef6135..7a9aca7b 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -5,6 +5,9 @@ "description": "The long URL this short URL will redirect to", "type": "string" }, + "deviceLongUrls": { + "$ref": "./DeviceLongUrls.json" + }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", "type": "string", diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 2bd461d8..12afe6f4 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -163,6 +163,11 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -186,6 +191,11 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", + "deviceLongUrls": { + "android": null, + "ios": "https://shlink.io/ios", + "desktop": null + }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -208,6 +218,11 @@ "shortCode": "123bA", "shortUrl": "https://example.com/123bA", "longUrl": "https://www.google.com", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2015-10-01T20:34:16+02:00", "visitsSummary": { "total": 25, @@ -320,6 +335,11 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 0, diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index e0257c59..bf2889e5 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -1,6 +1,7 @@ { "get": { "operationId": "shortenUrl", + "deprecated": true, "tags": [ "Short URLs" ], @@ -52,6 +53,11 @@ }, "example": { "longUrl": "https://github.com/shlinkio/shlink", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "shortUrl": "https://s.test/abc123", "shortCode": "abc123", "dateCreated": "2016-08-21T20:34:16+02:00", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index bd69b4ab..e639f362 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -40,6 +40,11 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -162,6 +167,11 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", + "deviceLongUrls": { + "android": "https://shlink.io/android", + "ios": null, + "desktop": null + }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 9063bdd7..4e93a916 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -25,8 +25,10 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function array_fill_keys; use function count; use function Functional\map; +use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate; @@ -330,7 +332,7 @@ class ShortUrl extends AbstractEntity public function deviceLongUrls(): array { - $data = []; + $data = array_fill_keys(enumValues(DeviceType::class), null); foreach ($this->deviceLongUrls as $deviceUrl) { $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 08327a98..9de5c408 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -27,6 +27,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => $this->stringifier->stringify($shortUrl), 'longUrl' => $shortUrl->getLongUrl(), + 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'tags' => invoke($shortUrl->getTags(), '__toString'), 'meta' => $this->buildMeta($shortUrl), diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 9611df99..924996f9 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -52,6 +52,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'longUrl', + 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => 0, 'tags' => [], @@ -129,6 +130,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'longUrl', + 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => 0, 'tags' => [], diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 903c3d3d..7851aa9b 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -20,6 +20,9 @@ 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; @@ -74,7 +77,7 @@ class ShortUrlServiceTest extends TestCase ); $resolveDeviceLongUrls = function () use ($shortUrlEdit): array { - $result = []; + $result = array_fill_keys(enumValues(DeviceType::class), null); foreach ($shortUrlEdit->deviceLongUrls ?? [] as $longUrl) { $result[$longUrl->deviceType->value] = $longUrl->longUrl; } diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 43d466e3..5cddfc50 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -6,6 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function count; @@ -169,109 +170,124 @@ class ListShortUrlsTest extends ApiTestCase public 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 ?? [ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + DeviceType::DESKTOP->value => null, + ], + ]; + $shortUrlMeta = $withDeviceLongUrls(self::SHORT_URL_META, [ + DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', + DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', + DeviceType::DESKTOP->value => null, + ]); + yield [[], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, - self::SHORT_URL_DOCS, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + $withDeviceLongUrls(self::SHORT_URL_DOCS), ], 'valid_api_key']; yield [['excludePastValidUntil' => 'true'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['excludeMaxVisitsReached' => 'true'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_DOCS, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_DOCS), ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ - self::SHORT_URL_SHLINK_WITH_TITLE, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_DOCS, - self::SHORT_URL_CUSTOM_DOMAIN, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_DOCS), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ - self::SHORT_URL_DOCS, - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_DOCS), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['orderBy' => 'title-DESC'], [ - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_DOCS, - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $withDeviceLongUrls(self::SHORT_URL_DOCS), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, ], 'valid_api_key']; yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, - self::SHORT_URL_DOCS, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + $withDeviceLongUrls(self::SHORT_URL_DOCS), ], 'valid_api_key']; yield [['tags' => ['foo']], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['bar']], [ - self::SHORT_URL_META, + $shortUrlMeta, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar']], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [ - self::SHORT_URL_META, + $shortUrlMeta, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz']], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key']; yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, ], 'valid_api_key']; yield [['searchTerm' => 'cool'], [ - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ - self::SHORT_URL_CUSTOM_DOMAIN, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), ], 'valid_api_key']; yield [[], [ - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'author_api_key']; yield [[], [ - self::SHORT_URL_CUSTOM_DOMAIN, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), ], 'domain_api_key']; } From 34129b8d24a8e3a8efc663b187406ff91b70b07c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Jan 2023 12:09:38 +0100 Subject: [PATCH 40/59] Update async API docs with device long URLs --- docs/async-api/async-api.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 418409cf..d45dae2b 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -111,6 +111,9 @@ "type": "string", "description": "The original long URL." }, + "deviceLongUrls": { + "$ref": "#/components/schemas/DeviceLongUrls" + }, "dateCreated": { "type": "string", "format": "date-time", @@ -152,6 +155,11 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", + "deviceLongUrls": { + "android": "https://store.steampowered.com/android", + "ios": "https://store.steampowered.com/ios", + "desktop": null + }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -215,6 +223,24 @@ } } }, + "DeviceLongUrls": { + "type": "object", + "required": ["android", "ios", "desktop"], + "properties": { + "android": { + "description": "The long URL to redirect to when the short URL is visited from a device running Android", + "type": "string" + }, + "ios": { + "description": "The long URL to redirect to when the short URL is visited from a device running iOS", + "type": "string" + }, + "desktop": { + "description": "The long URL to redirect to when the short URL is visited from a desktop browser", + "type": "string" + } + } + }, "Visit": { "type": "object", "properties": { From 45961144b937e2f9010485dce961352476ef2c80 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Jan 2023 12:13:42 +0100 Subject: [PATCH 41/59] Update changelog --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ce701d..58031200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type. + + For the moment, only `android`, `ios` and `desktop` can have their own specific long URL, and when the visitor cannot be matched against any of them, the regular long URL will be used. + + In the future, more granular device types could be added if appropriate (iOS tablet, android table, tablet, mobile phone, Linux, Mac, Windows, etc). + + In order to match the visitor's device, the `User-Agent` header is used. + * [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint. * [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint. * [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308. @@ -20,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Deprecated -* *Nothing* +* [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead ### Removed * *Nothing* From 13e443880a66e73f35443721b072e6cdb8ac57fd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 11:03:05 +0100 Subject: [PATCH 42/59] Allow device long URLs to be removed from short URLs by providing null value --- ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 2 + module/Core/src/ShortUrl/Entity/ShortUrl.php | 16 +++--- .../src/ShortUrl/Model/DeviceLongUrlPair.php | 22 ++++++-- .../src/ShortUrl/Model/ShortUrlCreation.php | 8 +-- .../src/ShortUrl/Model/ShortUrlEdition.php | 14 +++-- .../Validation/DeviceLongUrlsValidator.php | 4 +- .../Model/Validation/ShortUrlInputFilter.php | 22 +++++--- .../ShortUrl/Model/ShortUrlCreationTest.php | 35 ++++++++++++ .../ShortUrl/Model/ShortUrlEditionTest.php | 54 +++++++++++++++++++ .../DeviceLongUrlsValidatorTest.php | 6 +-- 10 files changed, 150 insertions(+), 33 deletions(-) create mode 100644 module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 13aa36f6..746ac3fd 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -70,6 +70,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class) ->mappedBy('shortUrl') ->cascadePersist() + ->orphanRemoval() + ->setIndexBy('deviceType') ->build(); $builder->createManyToMany('tags', Tag\Entity\Tag::class) diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 4e93a916..e6da743e 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -40,7 +40,7 @@ class ShortUrl extends AbstractEntity private Chronos $dateCreated; /** @var Collection */ private Collection $visits; - /** @var Collection */ + /** @var Collection */ private Collection $deviceLongUrls; /** @var Collection */ private Collection $tags; @@ -171,10 +171,13 @@ class ShortUrl extends AbstractEntity if ($shortUrlEdit->forwardQueryWasProvided()) { $this->forwardQuery = $shortUrlEdit->forwardQuery; } + + // Update device long URLs, removing, editing or creating where appropriate + foreach ($shortUrlEdit->devicesToRemove as $deviceType) { + $this->deviceLongUrls->remove($deviceType->value); + } foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { - $deviceLongUrl = $this->deviceLongUrls->findFirst( - fn ($_, DeviceLongUrl $d) => $d->deviceType === $deviceLongUrlPair->deviceType, - ); + $deviceLongUrl = $this->deviceLongUrls->get($deviceLongUrlPair->deviceType->value); if ($deviceLongUrl !== null) { $deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl); @@ -191,10 +194,7 @@ class ShortUrl extends AbstractEntity public function longUrlForDevice(?DeviceType $deviceType): string { - $deviceLongUrl = $this->deviceLongUrls->findFirst( - static fn ($_, DeviceLongUrl $longUrl) => $longUrl->deviceType === $deviceType, - ); - + $deviceLongUrl = $deviceType === null ? null : $this->deviceLongUrls->get($deviceType->value); return $deviceLongUrl?->longUrl() ?? $this->longUrl; } diff --git a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php index 6d0234ec..d017c7e5 100644 --- a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php +++ b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Shlinkio\Shlink\Core\Model\DeviceType; use function array_values; +use function Functional\group; use function Functional\map; use function trim; @@ -22,14 +23,25 @@ final class DeviceLongUrlPair } /** + * Returns an array with two values. + * * The first one is a list of mapped instances for those entries in the map with non-null value + * * The second is a list of DeviceTypes which have been provided with value null + * * @param array $map - * @return self[] + * @return array{array, DeviceType[]} */ - public static function fromMapToList(array $map): array + public static function fromMapToChangeSet(array $map): array { - return array_values(map( - $map, - fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl), + $typesWithNullUrl = group($map, static fn (?string $longUrl) => $longUrl === null ? 'remove' : 'keep'); + $deviceTypesToRemove = array_values(map( + $typesWithNullUrl['remove'] ?? [], + static fn ($_, string $deviceType) => DeviceType::from($deviceType), )); + $pairsToKeep = map( + $typesWithNullUrl['keep'] ?? [], + fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl), + ); + + return [$pairsToKeep, $deviceTypesToRemove]; } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index f2e156f4..a5d20bfb 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -61,11 +61,13 @@ final class ShortUrlCreation implements TitleResolutionModelInterface throw ValidationException::fromInputFilter($inputFilter); } + [$deviceLongUrls] = DeviceLongUrlPair::fromMapToChangeSet( + $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], + ); + return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - deviceLongUrls: DeviceLongUrlPair::fromMapToList( - $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - ), + deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 25645437..6bc157c7 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -19,11 +20,13 @@ final class ShortUrlEdition implements TitleResolutionModelInterface /** * @param string[] $tags * @param DeviceLongUrlPair[] $deviceLongUrls + * @param DeviceType[] $devicesToRemove */ private function __construct( private readonly bool $longUrlPropWasProvided = false, public readonly ?string $longUrl = null, public readonly array $deviceLongUrls = [], + public readonly array $devicesToRemove = [], private readonly bool $validSincePropWasProvided = false, public readonly ?Chronos $validSince = null, private readonly bool $validUntilPropWasProvided = false, @@ -53,12 +56,15 @@ final class ShortUrlEdition implements TitleResolutionModelInterface throw ValidationException::fromInputFilter($inputFilter); } + [$deviceLongUrls, $devicesToRemove] = DeviceLongUrlPair::fromMapToChangeSet( + $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], + ); + return new self( longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data), longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - deviceLongUrls: DeviceLongUrlPair::fromMapToList( - $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - ), + deviceLongUrls: $deviceLongUrls, + devicesToRemove: $devicesToRemove, validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data), validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data), @@ -82,6 +88,8 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return new self( longUrlPropWasProvided: $this->longUrlPropWasProvided, longUrl: $this->longUrl, + deviceLongUrls: $this->deviceLongUrls, + devicesToRemove: $this->devicesToRemove, validSincePropWasProvided: $this->validSincePropWasProvided, validSince: $this->validSince, validUntilPropWasProvided: $this->validUntilPropWasProvided, diff --git a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php index 1e9d9824..9fda1809 100644 --- a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php +++ b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; use Laminas\Validator\AbstractValidator; -use Laminas\Validator\ValidatorChain; +use Laminas\Validator\ValidatorInterface; use Shlinkio\Shlink\Core\Model\DeviceType; use function array_keys; @@ -27,7 +27,7 @@ class DeviceLongUrlsValidator extends AbstractValidator self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.', ]; - public function __construct(private readonly ValidatorChain $longUrlValidators) + public function __construct(private readonly ValidatorInterface $longUrlValidators) { parent::__construct(); } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 7b01841b..4a0e2d7b 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; -use DateTime; +use DateTimeInterface; use Laminas\Filter; use Laminas\InputFilter\InputFilter; use Laminas\Validator; @@ -41,6 +41,7 @@ class ShortUrlInputFilter extends InputFilter private function __construct(array $data, bool $requireLongUrl) { + // FIXME The multi-segment slug option should be injected $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->setData($data); } @@ -57,29 +58,36 @@ class ShortUrlInputFilter extends InputFilter private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void { - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); - $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ + $longUrlNotEmptyCommonOptions = [ Validator\NotEmpty::OBJECT, Validator\NotEmpty::SPACE, - Validator\NotEmpty::NULL, Validator\NotEmpty::EMPTY_ARRAY, Validator\NotEmpty::BOOLEAN, Validator\NotEmpty::STRING, + ]; + + $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ + ...$longUrlNotEmptyCommonOptions, + Validator\NotEmpty::NULL, ])); $this->add($longUrlInput); $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); $deviceLongUrlsInput->getValidatorChain()->attach( - new DeviceLongUrlsValidator($longUrlInput->getValidatorChain()), + new DeviceLongUrlsValidator(new Validator\NotEmpty([ + ...$longUrlNotEmptyCommonOptions, + ...($requireLongUrl ? [Validator\NotEmpty::NULL] : []), + ])), ); $this->add($deviceLongUrlsInput); $validSince = $this->createInput(self::VALID_SINCE, false); - $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); + $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validSince); $validUntil = $this->createInput(self::VALID_UNTIL, false); - $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); + $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validUntil); // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 33380ecf..4d11289c 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use stdClass; @@ -69,6 +70,40 @@ class ShortUrlCreationTest extends TestCase yield [[ ShortUrlInputFilter::LONG_URL => [], ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => null, + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + 'invalid' => 'https://shlink.io', + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::DESKTOP->value => '', + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::DESKTOP->value => null, + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::IOS->value => ' ', + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::IOS->value => 'bar', + DeviceType::ANDROID->value => [], + ], + ]]; } /** diff --git a/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php b/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php new file mode 100644 index 00000000..e03bb1ac --- /dev/null +++ b/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php @@ -0,0 +1,54 @@ + $deviceLongUrls]); + + self::assertEquals($expectedDeviceLongUrls, $edition->deviceLongUrls); + self::assertEquals($expectedDevicesToRemove, $edition->devicesToRemove); + } + + public function provideDeviceLongUrls(): iterable + { + yield 'null' => [null, [], []]; + yield 'empty' => [[], [], []]; + yield 'only new urls' => [[ + DeviceType::DESKTOP->value => 'foo', + DeviceType::IOS->value => 'bar', + ], [ + DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::DESKTOP->value, 'foo'), + DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'bar'), + ], []]; + yield 'only urls to remove' => [[ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + ], [], [DeviceType::ANDROID, DeviceType::IOS]]; + yield 'both' => [[ + DeviceType::DESKTOP->value => 'bar', + DeviceType::IOS->value => 'foo', + DeviceType::ANDROID->value => null, + ], [ + DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::DESKTOP->value, 'bar'), + DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'foo'), + ], [DeviceType::ANDROID]]; + } +} diff --git a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php index 42ad720b..8bac2f98 100644 --- a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php +++ b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Model\Validation; use Laminas\Validator\NotEmpty; -use Laminas\Validator\ValidatorChain; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\DeviceLongUrlsValidator; @@ -17,10 +16,7 @@ class DeviceLongUrlsValidatorTest extends TestCase protected function setUp(): void { - $longUrlValidators = new ValidatorChain(); - $longUrlValidators->attach(new NotEmpty()); - - $this->validator = new DeviceLongUrlsValidator($longUrlValidators); + $this->validator = new DeviceLongUrlsValidator(new NotEmpty()); } /** From 39adef8ab828271855813a6cac100fe506f8553f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 11:27:16 +0100 Subject: [PATCH 43/59] Make it impossible to create a short URL with an empty long URL --- .../Command/Domain/GetDomainVisitsCommandTest.php | 2 +- .../Command/ShortUrl/CreateShortUrlCommandTest.php | 8 ++++---- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 2 +- .../CLI/test/Command/Tag/GetTagVisitsCommandTest.php | 2 +- .../Command/Visit/GetNonOrphanVisitsCommandTest.php | 2 +- .../test/Command/Visit/LocateVisitsCommandTest.php | 6 +++--- module/Core/src/ShortUrl/Entity/ShortUrl.php | 7 ++----- module/Core/src/ShortUrl/Model/ShortUrlCreation.php | 8 -------- .../ShortUrl/Repository/ShortUrlRepositoryTest.php | 1 - .../Visit/Repository/VisitLocationRepositoryTest.php | 2 +- module/Core/test/Action/QrCodeActionTest.php | 6 +++--- module/Core/test/EventDispatcher/LocateVisitTest.php | 12 ++++++------ .../Mercure/NotifyVisitToMercureTest.php | 4 ++-- .../EventDispatcher/NotifyVisitToWebHooksTest.php | 2 +- .../test/Importer/ImportedLinksProcessorTest.php | 6 +++--- .../Core/test/ShortUrl/DeleteShortUrlServiceTest.php | 4 ++-- module/Core/test/ShortUrl/Entity/ShortUrlTest.php | 4 ++-- .../Core/test/ShortUrl/ShortUrlListServiceTest.php | 8 ++++---- .../Transformer/ShortUrlDataTransformerTest.php | 2 +- module/Core/test/Visit/Entity/VisitTest.php | 4 ++-- module/Core/test/Visit/VisitsStatsHelperTest.php | 10 +++++----- module/Core/test/Visit/VisitsTrackerTest.php | 2 +- .../Action/ShortUrl/CreateShortUrlActionTest.php | 2 +- .../test/Action/ShortUrl/EditShortUrlActionTest.php | 2 +- .../ShortUrl/SingleStepCreateShortUrlActionTest.php | 2 +- 25 files changed, 49 insertions(+), 61 deletions(-) diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index 913e00dc..5cda6dc3 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetDomainVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 1a8df888..69ce0c72 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -48,7 +48,7 @@ class CreateShortUrlCommandTest extends TestCase /** @test */ public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl); $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( 'stringified_short_url', @@ -98,7 +98,7 @@ class CreateShortUrlCommandTest extends TestCase /** @test */ public function properlyProcessesProvidedTags(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $creation) { Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags); @@ -130,7 +130,7 @@ class CreateShortUrlCommandTest extends TestCase Assert::assertEquals($expectedDomain, $meta->domain); return true; }), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $input['longUrl'] = 'http://domain.com/foo/bar'; @@ -153,7 +153,7 @@ class CreateShortUrlCommandTest extends TestCase */ public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 8706699b..bd2be187 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -94,7 +94,7 @@ class GetShortUrlVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $shortCode = 'abc123'; diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index be56cdee..b7255d0a 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 90147541..c780208a 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 518d9f45..44638249 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -66,7 +66,7 @@ class LocateVisitsCommandTest extends TestCase bool $expectWarningPrint, array $args, ): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); @@ -113,7 +113,7 @@ class LocateVisitsCommandTest extends TestCase */ public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); @@ -140,7 +140,7 @@ class LocateVisitsCommandTest extends TestCase /** @test */ public function errorWhileLocatingIpIsDisplayed(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e6da743e..9b266b4a 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -62,12 +62,9 @@ class ShortUrl extends AbstractEntity { } - /** - * @deprecated This should not be allowed - */ - public static function createEmpty(): self + public static function createFake(): self { - return self::create(ShortUrlCreation::createEmpty()); + return self::withLongUrl('foo'); } /** diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index a5d20bfb..d5078f7b 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -43,14 +43,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface ) { } - /** - * @deprecated This should not be allowed - */ - public static function createEmpty(): self - { - return new self(''); - } - /** * @throws ValidationException */ diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 99e3add9..0d90675a 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -134,7 +134,6 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneMatchingReturnsNullForNonExistingShortUrls(): void { - self::assertNull($this->repo->findOneMatching(ShortUrlCreation::createEmpty())); self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'foobar']))); self::assertNull($this->repo->findOneMatching( ShortUrlCreation::fromRawData(['longUrl' => 'foobar', 'tags' => ['foo', 'bar']]), diff --git a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php index 77f4c1e6..6b7c1e0d 100644 --- a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php @@ -31,7 +31,7 @@ class VisitLocationRepositoryTest extends DatabaseTestCase */ public function findVisitsReturnsProperVisits(int $blockSize): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->getEntityManager()->persist($shortUrl); for ($i = 0; $i < 6; $i++) { diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 599a09d9..684e9217 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -56,7 +56,7 @@ class QrCodeActionTest extends TestCase $shortCode = 'abc123'; $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $delegate = $this->createMock(RequestHandlerInterface::class); $delegate->expects($this->never())->method('handle'); @@ -78,7 +78,7 @@ class QrCodeActionTest extends TestCase $code = 'abc123'; $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $delegate = $this->createMock(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); @@ -111,7 +111,7 @@ class QrCodeActionTest extends TestCase $code = 'abc123'; $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $delegate = $this->createMock(RequestHandlerInterface::class); $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index cad6d164..d538bff0 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -70,7 +70,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), ); $this->em->expects($this->never())->method('flush'); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(false); @@ -89,7 +89,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), ); $this->em->expects($this->never())->method('flush'); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); @@ -110,7 +110,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), ); $this->em->expects($this->never())->method('flush'); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); @@ -148,7 +148,7 @@ class LocateVisitTest extends TestCase public function provideNonLocatableVisits(): iterable { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))]; yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))]; @@ -183,11 +183,11 @@ class LocateVisitTest extends TestCase public function provideIpAddresses(): iterable { yield 'no original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), null, ]; yield 'original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', ]; yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 1cecada7..23450fd3 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -59,7 +59,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentWhenVisitIsFound(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); @@ -79,7 +79,7 @@ class NotifyVisitToMercureTest extends TestCase public function debugIsLoggedWhenExceptionIsThrown(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 7a5cb888..17b26a74 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -123,7 +123,7 @@ class NotifyVisitToWebHooksTest extends TestCase public function provideVisits(): iterable { yield 'regular visit' => [ - Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), ['shortUrl', 'visit'], ]; yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit'],]; diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index c480e11a..f1b2f3bb 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -210,7 +210,7 @@ class ImportedLinksProcessorTest extends TestCase ]), 'Skipped. Imported 4 visits', 4, - ShortUrl::createEmpty(), + ShortUrl::createFake(), ]; yield 'existing short URL with previous imported visits' => [ $createImportedUrl([ @@ -222,8 +222,8 @@ class ImportedLinksProcessorTest extends TestCase ]), 'Skipped. Imported 2 visits', 2, - ShortUrl::createEmpty()->setVisits(new ArrayCollection([ - Visit::fromImport(ShortUrl::createEmpty(), new ImportedShlinkVisit('', '', $now, null)), + ShortUrl::createFake()->setVisits(new ArrayCollection([ + Visit::fromImport(ShortUrl::createFake(), new ImportedShlinkVisit('', '', $now, null)), ])), ]; } diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index be036264..3173e2ee 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -29,8 +29,8 @@ class DeleteShortUrlServiceTest extends TestCase protected function setUp(): void { - $shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection( - map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())), + $shortUrl = ShortUrl::createFake()->setVisits(new ArrayCollection( + map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index fd4515fb..d83ff3d7 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -42,7 +42,7 @@ class ShortUrlTest extends TestCase 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', ]; yield 'already persisted' => [ - ShortUrl::createEmpty()->setId('1'), + ShortUrl::createFake()->setId('1'), 'The short code can be regenerated only on new ShortUrls which have not been persisted yet.', ]; } @@ -64,7 +64,7 @@ class ShortUrlTest extends TestCase public function provideValidShortUrls(): iterable { - yield 'no custom slug' => [ShortUrl::createEmpty()]; + yield 'no custom slug' => [ShortUrl::createFake()]; yield 'imported with custom slug' => [ShortUrl::fromImport( new ImportedShlinkUrl(ImportSource::BITLY, 'longUrl', [], Chronos::now(), null, 'custom-slug', null), true, diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index be8eb852..446e95eb 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -36,10 +36,10 @@ class ShortUrlListServiceTest extends TestCase public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), + ShortUrl::createFake(), + ShortUrl::createFake(), + ShortUrl::createFake(), + ShortUrl::createFake(), ]; $this->repo->expects($this->once())->method('findList')->willReturn($list); diff --git a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index 7a97d4da..6159294b 100644 --- a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -38,7 +38,7 @@ class ShortUrlDataTransformerTest extends TestCase $maxVisits = random_int(1, 1000); $now = Chronos::now(); - yield 'no metadata' => [ShortUrl::createEmpty(), [ + yield 'no metadata' => [ShortUrl::createFake(), [ 'validSince' => null, 'validUntil' => null, 'maxVisits' => null, diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 7024c946..5ae22005 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -18,7 +18,7 @@ class VisitTest extends TestCase */ public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); self::assertEquals([ 'referer' => 'some site', @@ -48,7 +48,7 @@ class VisitTest extends TestCase public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void { $visit = Visit::forValidShortUrl( - ShortUrl::createEmpty(), + ShortUrl::createFake(), new Visitor('Chrome', 'some site', $address, ''), $anonymize, ); diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 1774ba6a..0ed06e7d 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -86,7 +86,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(ShortUrlRepositoryInterface::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByShortCode')->with( $identifier, @@ -146,7 +146,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(TagRepository::class); $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn( $list, @@ -187,7 +187,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( $domain, @@ -217,7 +217,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(DomainRepository::class); $repo->expects($this->never())->method('domainExists'); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( 'DEFAULT', @@ -259,7 +259,7 @@ class VisitsStatsHelperTest extends TestCase /** @test */ public function nonOrphanVisitsAreReturnedAsExpected(): void { - $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countNonOrphanVisits')->with( $this->isInstanceOf(VisitsCountFiltering::class), diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index d981f755..9c27d5df 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -58,7 +58,7 @@ class VisitsTrackerTest extends TestCase public function provideTrackingMethodNames(): iterable { - yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]]; + yield 'track' => ['track', [ShortUrl::createFake(), Visitor::emptyInstance()]]; yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]]; yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 246b2edf..15ce5389 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -37,7 +37,7 @@ class CreateShortUrlActionTest extends TestCase public function properShortcodeConversionReturnsData(): void { $apiKey = ApiKey::create(); - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $expectedMeta = $body = [ 'longUrl' => 'http://www.domain.com/foo/bar', 'validSince' => Chronos::now()->toAtomString(), diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index dde17ca6..ac788fa7 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -49,7 +49,7 @@ class EditShortUrlActionTest extends TestCase ->withParsedBody([ 'maxVisits' => 5, ]); - $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(ShortUrl::createEmpty()); + $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(ShortUrl::createFake()); $resp = $this->action->handle($request); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 14848696..42c185c9 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -43,7 +43,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase ])->withAttribute(ApiKey::class, $apiKey); $this->urlShortener->expects($this->once())->method('shorten')->with( ShortUrlCreation::fromRawData(['apiKey' => $apiKey, 'longUrl' => 'http://foobar.com']), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $resp = $this->action->handle($request); From d3590234a37e2c396e37867644bdfbaef322ac36 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 11:36:00 +0100 Subject: [PATCH 44/59] Add API test for short URL creation with device long URLs --- .../test-api/Action/CreateShortUrlTest.php | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 19bd6c74..4300c852 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -118,7 +118,7 @@ class CreateShortUrlTest extends ApiTestCase public function provideMaxVisits(): array { - return map(range(10, 15), fn (int $i) => [$i]); + return map(range(10, 15), fn(int $i) => [$i]); } /** @test */ @@ -172,12 +172,14 @@ class CreateShortUrlTest extends ApiTestCase yield 'only long URL' => [['longUrl' => $longUrl]]; yield 'long URL and tags' => [['longUrl' => $longUrl, 'tags' => ['boo', 'far']]]; yield 'long URL and custom slug' => [['longUrl' => $longUrl, 'customSlug' => 'my cool slug']]; - yield 'several params' => [[ - 'longUrl' => $longUrl, - 'tags' => ['boo', 'far'], - 'validSince' => Chronos::now()->toAtomString(), - 'maxVisits' => 7, - ]]; + yield 'several params' => [ + [ + 'longUrl' => $longUrl, + 'tags' => ['boo', 'far'], + 'validSince' => Chronos::now()->toAtomString(), + 'maxVisits' => 7, + ] + ]; } /** @@ -269,12 +271,12 @@ class CreateShortUrlTest extends ApiTestCase * @test * @dataProvider provideInvalidArgumentApiVersions */ - public function failsToCreateShortUrlWithoutLongUrl(string $version, string $expectedType): void + public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void { $resp = $this->callApiWithKey( self::METHOD_POST, sprintf('/rest/v%s/short-urls', $version), - [RequestOptions::JSON => []], + [RequestOptions::JSON => $payload], ); $payload = $this->getJsonResponsePayload($resp); @@ -287,8 +289,22 @@ class CreateShortUrlTest extends ApiTestCase public function provideInvalidArgumentApiVersions(): iterable { - yield ['2', 'INVALID_ARGUMENT']; - yield ['3', 'https://shlink.io/api/error/invalid-data']; + yield 'missing long url v2' => [[], '2', 'INVALID_ARGUMENT']; + yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data']; + yield 'empty long url v2' => [['longUrl' => null], '2', 'INVALID_ARGUMENT']; + yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data']; + yield 'empty device long url v2' => [[ + 'longUrl' => 'foo', + 'deviceLongUrls' => [ + 'android' => null, + ], + ], '2', 'INVALID_ARGUMENT']; + yield 'empty device long url v3' => [[ + 'longUrl' => 'foo', + 'deviceLongUrls' => [ + 'ios' => ' ', + ], + ], '3', 'https://shlink.io/api/error/invalid-data']; } /** @test */ @@ -361,6 +377,22 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals('http://s.test/🦣🦣🦣', $payload['shortUrl']); } + /** @test */ + public function canCreateShortUrlsWithDeviceLongUrls(): void + { + [$statusCode, $payload] = $this->createShortUrl([ + 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', + 'deviceLongUrls' => [ + 'ios' => 'https://github.com/shlinkio/shlink/ios', + 'android' => 'https://github.com/shlinkio/shlink/android', + ], + ]); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals('https://github.com/shlinkio/shlink/ios', $payload['deviceLongUrls']['ios'] ?? null); + self::assertEquals('https://github.com/shlinkio/shlink/android', $payload['deviceLongUrls']['android'] ?? null); + } + /** * @return array{int, array} */ From b18c9e495fa5260eb5f756f764556962171d326f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 11:47:45 +0100 Subject: [PATCH 45/59] Add API test for short URL edition with device long URLs --- .../Rest/test-api/Action/EditShortUrlTest.php | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index fefbdcba..74fdebc5 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -154,7 +154,7 @@ class EditShortUrlTest extends ApiTestCase $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ 'maxVisits' => 100, ]]); - $editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url)); + $editedShortUrl = $this->getJsonResponsePayload($editResp); self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); self::assertEquals($domain, $editedShortUrl['domain']); @@ -170,4 +170,27 @@ class EditShortUrlTest extends ApiTestCase ]; yield 'no domain' => [null, 'https://shlink.io/documentation/']; } + + /** @test */ + public function deviceLongUrlsCanBeEdited(): void + { + $shortCode = 'def456'; + $url = new Uri(sprintf('/short-urls/%s', $shortCode)); + $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ + 'deviceLongUrls' => [ + 'android' => null, // This one will get removed + 'ios' => 'https://blog.alejandrocelaya.com/ios/edited', // This one will be edited + 'desktop' => 'https://blog.alejandrocelaya.com/desktop', // This one is new and will be created + ], + ]]); + $deviceLongUrls = $this->getJsonResponsePayload($editResp)['deviceLongUrls'] ?? []; + + self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); + self::assertArrayHasKey('ios', $deviceLongUrls); + self::assertEquals('https://blog.alejandrocelaya.com/ios/edited', $deviceLongUrls['ios']); + self::assertArrayHasKey('desktop', $deviceLongUrls); + self::assertEquals('https://blog.alejandrocelaya.com/desktop', $deviceLongUrls['desktop']); + self::assertArrayHasKey('android', $deviceLongUrls); + self::assertNull($deviceLongUrls['android']); + } } From 5aa8de11f492147e474a3575dfea48809435c55a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 12:00:16 +0100 Subject: [PATCH 46/59] =?UTF-8?q?Update=20version=20on=20user=20agent=20us?= =?UTF-8?q?ed=20to=20validate=20URLs=C3=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/Core/src/Util/UrlValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 0057660a..8a7d0b89 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -26,7 +26,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface { private const MAX_REDIRECTS = 15; private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' - . 'Chrome/51.0.2704.103 Safari/537.36'; + . 'Chrome/108.0.0.0 Safari/537.36'; public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options) { From b0b9902f404a60a5112ef2ab7255dc4be0137c98 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 12:15:29 +0100 Subject: [PATCH 47/59] Add unit test to cover device URLs edition, and fix bug thanks to it --- module/Core/src/ShortUrl/Entity/ShortUrl.php | 5 ++- .../test/ShortUrl/Entity/ShortUrlTest.php | 44 +++++++++++++++++++ .../test-api/Action/CreateShortUrlTest.php | 2 +- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 9b266b4a..d0e9cba4 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -174,12 +174,13 @@ class ShortUrl extends AbstractEntity $this->deviceLongUrls->remove($deviceType->value); } foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { - $deviceLongUrl = $this->deviceLongUrls->get($deviceLongUrlPair->deviceType->value); + $key = $deviceLongUrlPair->deviceType->value; + $deviceLongUrl = $this->deviceLongUrls->get($key); if ($deviceLongUrl !== null) { $deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl); } else { - $this->deviceLongUrls->add(DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair)); + $this->deviceLongUrls->set($key, DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair)); } } } diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index d83ff3d7..2d950d5f 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -7,8 +7,10 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; @@ -89,4 +91,46 @@ class ShortUrlTest extends TestCase yield [null, DEFAULT_SHORT_CODES_LENGTH]; yield from map(range(4, 10), fn (int $value) => [$value, $value]); } + + /** @test */ + public function deviceLongUrlsAreUpdated(): void + { + $shortUrl = ShortUrl::withLongUrl('foo'); + + $shortUrl->update(ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::ANDROID->value => 'android', + DeviceType::IOS->value => 'ios', + ], + ])); + self::assertEquals([ + DeviceType::ANDROID->value => 'android', + DeviceType::IOS->value => 'ios', + DeviceType::DESKTOP->value => null, + ], $shortUrl->deviceLongUrls()); + + $shortUrl->update(ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::ANDROID->value => null, + DeviceType::DESKTOP->value => 'desktop', + ], + ])); + self::assertEquals([ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => 'ios', + DeviceType::DESKTOP->value => 'desktop', + ], $shortUrl->deviceLongUrls()); + + $shortUrl->update(ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + ], + ])); + self::assertEquals([ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + DeviceType::DESKTOP->value => 'desktop', + ], $shortUrl->deviceLongUrls()); + } } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 4300c852..0bb02c9e 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -178,7 +178,7 @@ class CreateShortUrlTest extends ApiTestCase 'tags' => ['boo', 'far'], 'validSince' => Chronos::now()->toAtomString(), 'maxVisits' => 7, - ] + ], ]; } From 9949bb654d799f6acf34764f068692682d20789e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 12:35:07 +0100 Subject: [PATCH 48/59] Set more accurate swagger docs in terms of what props are required/nullable for device long URLs --- docs/swagger/definitions/DeviceLongUrls.json | 6 +++--- .../swagger/definitions/DeviceLongUrlsEdit.json | 17 +++++++++++++++++ .../swagger/definitions/DeviceLongUrlsResp.json | 2 +- docs/swagger/definitions/ShortUrlEdition.json | 2 +- docs/swagger/paths/v1_short-urls.json | 3 +++ docs/swagger/paths/v1_short-urls_shorten.json | 2 +- 6 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 docs/swagger/definitions/DeviceLongUrlsEdit.json diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json index 25e7f322..1a56d9ef 100644 --- a/docs/swagger/definitions/DeviceLongUrls.json +++ b/docs/swagger/definitions/DeviceLongUrls.json @@ -4,17 +4,17 @@ "android": { "description": "The long URL to redirect to when the short URL is visited from a device running Android", "type": "string", - "nullable": true + "nullable": false }, "ios": { "description": "The long URL to redirect to when the short URL is visited from a device running iOS", "type": "string", - "nullable": true + "nullable": false }, "desktop": { "description": "The long URL to redirect to when the short URL is visited from a desktop browser", "type": "string", - "nullable": true + "nullable": false } } } diff --git a/docs/swagger/definitions/DeviceLongUrlsEdit.json b/docs/swagger/definitions/DeviceLongUrlsEdit.json new file mode 100644 index 00000000..78f77e46 --- /dev/null +++ b/docs/swagger/definitions/DeviceLongUrlsEdit.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "allOf": [{ + "$ref": "./DeviceLongUrls.json" + }], + "properties": { + "android": { + "nullable": true + }, + "ios": { + "nullable": true + }, + "desktop": { + "nullable": true + } + } +} diff --git a/docs/swagger/definitions/DeviceLongUrlsResp.json b/docs/swagger/definitions/DeviceLongUrlsResp.json index 64a56c01..95724581 100644 --- a/docs/swagger/definitions/DeviceLongUrlsResp.json +++ b/docs/swagger/definitions/DeviceLongUrlsResp.json @@ -2,6 +2,6 @@ "type": "object", "required": ["android", "ios", "desktop"], "allOf": [{ - "$ref": "./DeviceLongUrls.json" + "$ref": "./DeviceLongUrlsEdit.json" }] } diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index 7a9aca7b..28fa71bc 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -6,7 +6,7 @@ "type": "string" }, "deviceLongUrls": { - "$ref": "./DeviceLongUrls.json" + "$ref": "./DeviceLongUrlsEdit.json" }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 12afe6f4..76d87659 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -296,6 +296,9 @@ "type": "object", "required": ["longUrl"], "properties": { + "deviceLongUrls": { + "$ref": "../definitions/DeviceLongUrls.json" + }, "customSlug": { "description": "A unique custom slug to be used instead of the generated short code", "type": "string" diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index bf2889e5..cacb00bb 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -6,7 +6,7 @@ "Short URLs" ], "summary": "Create a short URL", - "description": "Creates a short URL in a single API call. Useful for third party integrations.", + "description": "**[Deprecated]** Use [Create short URL](#/Short%20URLs/createShortUrl) instead", "parameters": [ { "$ref": "../parameters/version.json" From 81393a76b4c4f52b671798e663efcfffb9069fda Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 12:43:03 +0100 Subject: [PATCH 49/59] Ensure GITHUB_TOKEN is exposed to roadrunner api tests workflow --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a95f8f21..2ccb5ae7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,8 @@ jobs: strategy: matrix: php-version: ['8.1', '8.2'] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - uses: actions/checkout@v3 - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres From f3855dbc6ffcaf5202eccbb10e10a40fad0d0175 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 20:57:48 +0100 Subject: [PATCH 50/59] Updated to openswoole 4.12.1 --- .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 | 2 +- .github/workflows/publish-swagger-spec.yml | 2 +- Dockerfile | 2 +- data/infra/swoole.Dockerfile | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index db3efacf..6bf9ad2c 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -27,7 +27,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0, pdo_sqlsrv-5.10.1 + php-extensions: openswoole-4.12.1, pdo_sqlsrv-5.10.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 ac510c7d..5d8b1660 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -19,7 +19,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 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 f7e7b141..ba9fb991 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -25,7 +25,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 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 2ccb5ae7..546f4843 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-4.12.0 + php-extensions: openswoole-4.12.1 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 792513be..8d8a4b0d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,7 +17,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 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 6e6cb925..dd5bfbde 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-4.12.0 + php-extensions: openswoole-4.12.1 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 8c38653b..935c3d44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ARG SHLINK_RUNTIME=openswoole ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} -ENV OPENSWOOLE_VERSION 4.12.0 +ENV OPENSWOOLE_VERSION 4.12.1 ENV PDO_SQLSRV_VERSION 5.10.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 21e7d95f..6cab2561 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 4.12.0 +ENV OPENSWOOLE_VERSION 4.12.1 ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 From 024c9c1a7aab78362a787f3bba1ba9bcc9f911cb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Jan 2023 21:01:46 +0100 Subject: [PATCH 51/59] Fixed paths glob patterns in some workflows --- .github/workflows/ci-docker-image-build.yml | 2 +- .github/workflows/ci.yml | 24 ++++++++++----------- .github/workflows/publish-docker-image.yml | 12 +++++------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci-docker-image-build.yml b/.github/workflows/ci-docker-image-build.yml index 690a365d..3a055f10 100644 --- a/.github/workflows/ci-docker-image-build.yml +++ b/.github/workflows/ci-docker-image-build.yml @@ -3,7 +3,7 @@ name: Build docker image on: pull_request: paths: - - './Dockerfile' + - 'Dockerfile' jobs: build-docker-image: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 546f4843..c5749074 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,12 @@ on: pull_request: paths-ignore: - 'LICENSE' - - './.*' - - './*.md' - - './*.xml' - - './*.yml*' - - './*.json5' - - './*.neon' + - '.*' + - '*.md' + - '*.xml' + - '*.yml*' + - '*.json5' + - '*.neon' push: branches: - main @@ -17,12 +17,12 @@ on: - 2.x paths-ignore: - 'LICENSE' - - './.*' - - './*.md' - - './*.xml' - - './*.yml*' - - './*.json5' - - './*.neon' + - '.*' + - '*.md' + - '*.xml' + - '*.yml*' + - '*.json5' + - '*.neon' jobs: static-analysis: diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index 2842f505..7fb52fe1 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -6,12 +6,12 @@ on: - develop paths-ignore: - 'LICENSE' - - './.*' - - './*.md' - - './*.xml' - - './*.yml*' - - './*.json5' - - './*.neon' + - '.*' + - '*.md' + - '*.xml' + - '*.yml*' + - '*.json5' + - '*.neon' tags: - 'v*' From 4ee0032c2aa7bf8a67baea7b516a09154b493a54 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 23 Jan 2023 20:30:12 +0100 Subject: [PATCH 52/59] Deprecated validateUrl option on short URL creation/edition --- CHANGELOG.md | 3 ++- docs/swagger/definitions/ShortUrlEdition.json | 3 ++- docs/swagger/paths/v1_short-urls.json | 4 ---- module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php | 2 +- .../src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php | 2 ++ .../Helper/ShortUrlTitleResolutionHelperInterface.php | 1 + .../src/ShortUrl/Helper/TitleResolutionModelInterface.php | 1 + module/Core/src/ShortUrl/Model/ShortUrlCreation.php | 2 ++ module/Core/src/ShortUrl/Model/ShortUrlEdition.php | 2 ++ .../src/ShortUrl/Model/Validation/ShortUrlInputFilter.php | 1 + module/Core/src/Util/UrlValidator.php | 6 ++++++ module/Core/src/Util/UrlValidatorInterface.php | 3 +++ 12 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58031200..5384703e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Deprecated -* [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead +* [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead. +* [#1678](https://github.com/shlinkio/shlink/issues/1678) Deprecated `validateUrl` option on URL creation/edition. ### Removed * *Nothing* diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index 28fa71bc..ed3c3929 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -24,7 +24,8 @@ "nullable": true }, "validateUrl": { - "description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", + "deprecated": true, + "description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`", "type": "boolean" }, "tags": { diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 76d87659..c226046f 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -314,10 +314,6 @@ "shortCodeLength": { "description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided", "type": "number" - }, - "validateUrl": { - "description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", - "type": "boolean" } } } diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index f4cfc58a..71ab5fa7 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -103,7 +103,7 @@ class CreateShortUrlCommand extends Command 'validate-url', null, InputOption::VALUE_NONE, - 'Forces the long URL to be validated, regardless what is globally configured.', + '[DEPRECATED] Makes the URL to be validated as publicly accessible.', ) ->addOption( 'crawlable', diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index a4920cdd..71963437 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -14,6 +14,8 @@ class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInte } /** + * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 + * Move relevant logic from URL validator here. * @template T of TitleResolutionModelInterface * @param T $data * @return T diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php index 6989140a..1861b451 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; interface ShortUrlTitleResolutionHelperInterface { /** + * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 * @template T of TitleResolutionModelInterface * @param T $data * @return T diff --git a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php index 1c834331..4c56bfc1 100644 --- a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php +++ b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php @@ -10,6 +10,7 @@ interface TitleResolutionModelInterface public function getLongUrl(): string; + /** @deprecated */ public function doValidateUrl(): bool; public function withResolvedTitle(string $title): static; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index d5078f7b..c29817b6 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -33,6 +33,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface public readonly bool $findIfExists = false, public readonly ?string $domain = null, public readonly int $shortCodeLength = 5, + /** @deprecated */ public readonly bool $validateUrl = false, public readonly ?ApiKey $apiKey = null, public readonly array $tags = [], @@ -131,6 +132,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return $this->domain !== null; } + /** @deprecated */ public function doValidateUrl(): bool { return $this->validateUrl; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 6bc157c7..fe92fae8 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -38,6 +38,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface private readonly bool $titlePropWasProvided = false, public readonly ?string $title = null, public readonly bool $titleWasAutoResolved = false, + /** @deprecated */ public readonly bool $validateUrl = false, private readonly bool $crawlablePropWasProvided = false, public readonly bool $crawlable = false, @@ -154,6 +155,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->titleWasAutoResolved; } + /** @deprecated */ public function doValidateUrl(): bool { return $this->validateUrl; diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 4a0e2d7b..68b87158 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -32,6 +32,7 @@ class ShortUrlInputFilter extends InputFilter public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const LONG_URL = 'longUrl'; public const DEVICE_LONG_URLS = 'deviceLongUrls'; + /** @deprecated */ public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; public const TAGS = 'tags'; diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 8a7d0b89..762fdd9f 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -22,6 +22,7 @@ use function trim; use const Shlinkio\Shlink\TITLE_TAG_VALUE; +/** @deprecated */ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface { private const MAX_REDIRECTS = 15; @@ -33,6 +34,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface } /** + * @deprecated * @throws InvalidUrlException */ public function validateUrl(string $url, bool $doValidate): void @@ -44,6 +46,10 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface $this->validateUrlAndGetResponse($url); } + /** + * @deprecated + * @throws InvalidUrlException + */ public function validateUrlWithTitle(string $url, bool $doValidate): ?string { if (! $doValidate && ! $this->options->autoResolveTitles) { diff --git a/module/Core/src/Util/UrlValidatorInterface.php b/module/Core/src/Util/UrlValidatorInterface.php index 299bd22a..cb38dc42 100644 --- a/module/Core/src/Util/UrlValidatorInterface.php +++ b/module/Core/src/Util/UrlValidatorInterface.php @@ -6,14 +6,17 @@ namespace Shlinkio\Shlink\Core\Util; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +/** @deprecated */ interface UrlValidatorInterface { /** + * @deprecated * @throws InvalidUrlException */ public function validateUrl(string $url, bool $doValidate): void; /** + * @deprecated * @throws InvalidUrlException */ public function validateUrlWithTitle(string $url, bool $doValidate): ?string; From 05acd4ae88da0a1c4502b26b0ffd40df8a0752ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 25 Jan 2023 20:33:07 +0100 Subject: [PATCH 53/59] Add two modes for short URLs --- config/autoload/url-shortener.global.php | 4 +++ .../ShortUrl/CreateShortUrlCommand.php | 2 +- module/Core/config/dependencies.config.php | 2 +- module/Core/functions/functions.php | 7 ++++-- module/Core/src/Config/EnvVars.php | 1 + .../Core/src/Options/UrlShortenerOptions.php | 3 +++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 10 +++++--- .../Helper/ShortCodeUniquenessHelper.php | 9 ++++--- .../src/ShortUrl/Model/ShortUrlCreation.php | 5 +++- .../Core/src/ShortUrl/Model/ShortUrlMode.php | 9 +++++++ .../Model/Validation/ShortUrlInputFilter.php | 1 - .../test/ShortUrl/Entity/ShortUrlTest.php | 25 +++++++++++++++++-- .../Helper/ShortCodeUniquenessHelperTest.php | 3 ++- .../Action/ShortUrl/CreateShortUrlAction.php | 2 +- .../SingleStepCreateShortUrlAction.php | 2 +- 15 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 module/Core/src/ShortUrl/Model/ShortUrlMode.php diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index ec3c1409..2816577d 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; @@ -12,6 +13,8 @@ return (static function (): array { (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), MIN_SHORT_CODES_LENGTH, ); + $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); + $mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; return [ @@ -25,6 +28,7 @@ return (static function (): array { 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), + 'mode' => $mode, ], ]; diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 71ab5fa7..46a1cc8f 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -176,7 +176,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled, - ])); + ], $this->options->mode)); $io->writeln([ sprintf('Processed long URL: %s', $longUrl), diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 48fe3cb2..40985f42 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -137,7 +137,7 @@ return [ ShortUrl\ShortUrlResolver::class, ], ShortUrl\ShortUrlResolver::class => ['em'], - ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em'], + ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 574d604c..b6acbb35 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -15,6 +15,7 @@ use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use function date_default_timezone_get; use function Functional\map; @@ -27,14 +28,16 @@ use function str_repeat; use function strtolower; use function ucfirst; -function generateRandomShortCode(int $length): string +function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string { static $shortIdFactory; if ($shortIdFactory === null) { $shortIdFactory = new ShortIdFactory(); } - $alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $alphabet = $mode === ShortUrlMode::STRICT + ? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + : '0123456789abcdefghijklmnopqrstuvwxyz'; return $shortIdFactory->generate($length, $alphabet)->serialize(); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 75454ecc..44919415 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -43,6 +43,7 @@ enum EnvVars: string case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; case BASE_PATH = 'BASE_PATH'; case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; + case SHORT_URL_MODE = 'SHORT_URL_MODE'; case PORT = 'PORT'; case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 9aacc085..827988fa 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; + use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class UrlShortenerOptions @@ -16,6 +18,7 @@ final class UrlShortenerOptions public readonly bool $appendExtraPath = false, public readonly bool $multiSegmentSlugsEnabled = false, public readonly bool $trailingSlashEnabled = false, + public readonly ShortUrlMode $mode = ShortUrlMode::STRICT, ) { } } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index d0e9cba4..e3d6544c 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; @@ -95,7 +96,10 @@ class ShortUrl extends AbstractEntity $instance->maxVisits = $creation->maxVisits; $instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->shortCodeLength = $creation->shortCodeLength; - $instance->shortCode = $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength); + $instance->shortCode = $creation->customSlug ?? generateRandomShortCode( + $instance->shortCodeLength, + $creation->shortUrlMode, + ); $instance->domain = $relationResolver->resolveDomain($creation->domain); $instance->authorApiKey = $creation->apiKey; $instance->title = $creation->title; @@ -292,7 +296,7 @@ class ShortUrl extends AbstractEntity /** * @throws ShortCodeCannotBeRegeneratedException */ - public function regenerateShortCode(): void + public function regenerateShortCode(ShortUrlMode $mode): void { // In ShortUrls where a custom slug was provided, throw error, unless it is an imported one if ($this->customSlugWasProvided && $this->importSource === null) { @@ -304,7 +308,7 @@ class ShortUrl extends AbstractEntity throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted(); } - $this->shortCode = generateRandomShortCode($this->shortCodeLength); + $this->shortCode = generateRandomShortCode($this->shortCodeLength, $mode); } public function isEnabled(): bool diff --git a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php index 1f16f037..b428019e 100644 --- a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php @@ -5,14 +5,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface { - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly UrlShortenerOptions $options, + ) { } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool @@ -29,7 +32,7 @@ class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface return false; } - $shortUrlToBeCreated->regenerateShortCode(); + $shortUrlToBeCreated->regenerateShortCode($this->options->mode); return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug); } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index c29817b6..d0d9b279 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -25,6 +25,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface */ private function __construct( public readonly string $longUrl, + public readonly ShortUrlMode $shortUrlMode, public readonly array $deviceLongUrls = [], public readonly ?Chronos $validSince = null, public readonly ?Chronos $validUntil = null, @@ -47,7 +48,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface /** * @throws ValidationException */ - public static function fromRawData(array $data): self + public static function fromRawData(array $data, ShortUrlMode $mode = ShortUrlMode::STRICT): self { $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); if (! $inputFilter->isValid()) { @@ -60,6 +61,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), + shortUrlMode: $mode, deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), @@ -84,6 +86,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface { return new self( longUrl: $this->longUrl, + shortUrlMode: $this->shortUrlMode, deviceLongUrls: $this->deviceLongUrls, validSince: $this->validSince, validUntil: $this->validUntil, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlMode.php b/module/Core/src/ShortUrl/Model/ShortUrlMode.php new file mode 100644 index 00000000..41698e18 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ShortUrlMode.php @@ -0,0 +1,9 @@ +initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->setData($data); } diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index 2d950d5f..be1747fd 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -11,13 +11,16 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; +use function Functional\every; use function Functional\map; use function range; use function strlen; +use function strtolower; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -34,7 +37,7 @@ class ShortUrlTest extends TestCase $this->expectException(ShortCodeCannotBeRegeneratedException::class); $this->expectExceptionMessage($expectedMessage); - $shortUrl->regenerateShortCode(); + $shortUrl->regenerateShortCode(ShortUrlMode::STRICT); } public function provideInvalidShortUrls(): iterable @@ -58,7 +61,7 @@ class ShortUrlTest extends TestCase ): void { $firstShortCode = $shortUrl->getShortCode(); - $shortUrl->regenerateShortCode(); + $shortUrl->regenerateShortCode(ShortUrlMode::STRICT); $secondShortCode = $shortUrl->getShortCode(); self::assertNotEquals($firstShortCode, $secondShortCode); @@ -133,4 +136,22 @@ class ShortUrlTest extends TestCase DeviceType::DESKTOP->value => 'desktop', ], $shortUrl->deviceLongUrls()); } + + /** @test */ + public function generatesLowercaseOnlyShortCodesInLooselyMode(): void + { + $range = range(1, 1000); // Use a "big" number to reduce false negatives + $allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( + [ShortUrlInputFilter::LONG_URL => 'foo'], + $mode, + )); + $shortCode = $shortUrl->getShortCode(); + + return $shortCode === strtolower($shortCode); + }); + + self::assertTrue($allFor(ShortUrlMode::LOOSELY)); + self::assertFalse($allFor(ShortUrlMode::STRICT)); + } } diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index 5df79fc5..ae0d9363 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Domain\Entity\Domain; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; @@ -22,7 +23,7 @@ class ShortCodeUniquenessHelperTest extends TestCase protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->helper = new ShortCodeUniquenessHelper($this->em); + $this->helper = new ShortCodeUniquenessHelper($this->em, new UrlShortenerOptions()); $this->shortUrl = $this->createMock(ShortUrl::class); $this->shortUrl->method('getShortCode')->willReturn('abc123'); diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index e60414b2..c7b6b33f 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -25,6 +25,6 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled; - return ShortUrlCreation::fromRawData($payload); + return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions->mode); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 89989dda..b32e8a5d 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -25,6 +25,6 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction ShortUrlInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), - ]); + ], $this->urlShortenerOptions->mode); } } From 2f83e90c8ba07edbcc948fa8efe6972d719689d1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 26 Jan 2023 20:45:36 +0100 Subject: [PATCH 54/59] Add option to do loosely matches on short URLs when mode is loosely --- module/Core/config/dependencies.config.php | 2 +- .../MultiSegmentSlugProcessor.php | 6 +-- .../Repository/ShortUrlRepository.php | 37 +++++++++---------- .../ShortUrlRepositoryInterface.php | 3 +- module/Core/src/ShortUrl/ShortUrlResolver.php | 9 +++-- .../Repository/ShortUrlRepositoryTest.php | 30 +++++++++++++-- .../test/ShortUrl/ShortUrlResolverTest.php | 6 ++- 7 files changed, 61 insertions(+), 32 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 40985f42..4555bff1 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -136,7 +136,7 @@ return [ Options\DeleteShortUrlsOptions::class, ShortUrl\ShortUrlResolver::class, ], - ShortUrl\ShortUrlResolver::class => ['em'], + ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class], ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], diff --git a/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php index b84491f6..33945063 100644 --- a/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php +++ b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php @@ -9,8 +9,8 @@ use function str_replace; class MultiSegmentSlugProcessor { - private const SINGLE_SHORT_CODE_PATTERN = '{shortCode}'; - private const MULTI_SHORT_CODE_PATTERN = '{shortCode:.+}'; + private const SINGLE_SEGMENT_PATTERN = '{shortCode}'; + private const MULTI_SEGMENT_PATTERN = '{shortCode:.+}'; public function __invoke(array $config): array { @@ -21,7 +21,7 @@ class MultiSegmentSlugProcessor $config['routes'] = map($config['routes'] ?? [], static function (array $route): array { ['path' => $path] = $route; - $route['path'] = str_replace(self::SINGLE_SHORT_CODE_PATTERN, self::MULTI_SHORT_CODE_PATTERN, $path); + $route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path); return $route; }); diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index ee2f7389..f8e384e2 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -14,42 +14,41 @@ use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function count; +use function strtolower; class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at // the bottom $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform(); $ordering = $dbPlatform instanceof PostgreSQLPlatform ? 'ASC' : 'DESC'; + $isStrict = $shortUrlMode === ShortUrlMode::STRICT; - $dql = <<createQueryBuilder('s'); + $qb->leftJoin('s.domain', 'd') + ->where($qb->expr()->eq($isStrict ? 's.shortCode' : 'LOWER(s.shortCode)', ':shortCode')) + ->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode)) + ->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.domain'), + $qb->expr()->eq('d.authority', ':domain') + )) + ->setParameter('domain', $identifier->domain); - $query = $this->getEntityManager()->createQuery($dql); - $query->setMaxResults(1) - ->setParameters([ - 'shortCode' => $identifier->shortCode, - 'domain' => $identifier->domain, - ]); - - // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one - // with no domain (if any), so it is safe to fetch 1 max result and we will get: + // Since we order by domain, we will have first the URL matching provided domain, followed by the one + // with no domain (if any), so it is safe to fetch 1 max result, and we will get: // * The short URL matching both the short code and the domain, or // * The short URL matching the short code but without any domain, or // * No short URL at all + $qb->orderBy('s.domain', $ordering) + ->setMaxResults(1); - return $query->getOneOrNullResult(); + return $qb->getQuery()->getOneOrNullResult(); } public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index 18a4ec71..8af53cb9 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -10,11 +10,12 @@ use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl; + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl; public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 20ec930b..2c4f7bdc 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; @@ -13,8 +14,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly UrlShortenerOptions $urlShortenerOptions, + ) { } /** @@ -39,7 +42,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier); + $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); if (! $shortUrl?->isEnabled()) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 0d90675a..b5633ee6 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -32,7 +33,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneWithDomainFallbackReturnsProperData(): void { - $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'foo', 'longUrl' => 'foo'])); + $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'Foo', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($regularOne); $withDomain = ShortUrl::create(ShortUrlCreation::fromRawData( @@ -41,7 +42,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($withDomain); $withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData( - ['domain' => 's.test', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], + ['domain' => 's.test', 'customSlug' => 'Foo', 'longUrl' => 'foo_with_domain'], )); $this->getEntityManager()->persist($withDomainDuplicatingRegular); @@ -49,29 +50,50 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($regularOne->getShortCode()), + ShortUrlMode::STRICT, )); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + ShortUrlMode::LOOSELY, + )); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('fOo'), + ShortUrlMode::LOOSELY, + )); +// self::assertNull($this->repo->findOneWithDomainFallback( // TODO MS is doing loosely checks always +// ShortUrlIdentifier::fromShortCodeAndDomain('foo'), +// ShortUrlMode::STRICT, +// )); self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()), + ShortUrlMode::STRICT, )); self::assertSame($withDomain, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'example.com'), + ShortUrlMode::STRICT, )); self::assertSame( $withDomainDuplicatingRegular, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 's.test'), + ShortUrlMode::STRICT, ), ); self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain( $withDomainDuplicatingRegular->getShortCode(), 'other-domain.com', - ))); - self::assertNull($this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain('invalid'))); + ), ShortUrlMode::STRICT)); + self::assertNull($this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('invalid'), + ShortUrlMode::STRICT, + )); self::assertNull($this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode()), + ShortUrlMode::STRICT, )); self::assertNull($this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'other-domain.com'), + ShortUrlMode::STRICT, )); } diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 177e432e..9c42fefb 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -10,9 +10,11 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -35,7 +37,7 @@ class ShortUrlResolverTest extends TestCase { $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); - $this->urlResolver = new ShortUrlResolver($this->em); + $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } /** @@ -83,6 +85,7 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ShortUrlMode::STRICT, )->willReturn($shortUrl); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); @@ -101,6 +104,7 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ShortUrlMode::STRICT, )->willReturn($shortUrl); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); From fdaf5fb2f3f1970cc14647838475490b7039b01f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Jan 2023 10:06:11 +0100 Subject: [PATCH 55/59] Add support for short URL mode in installer, and handle loosely mode in custom slugs --- composer.json | 2 +- config/autoload/installer.global.php | 1 + .../ShortUrl/CreateShortUrlCommand.php | 4 +-- .../Core/src/Options/UrlShortenerOptions.php | 5 +++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 4 +++ .../src/ShortUrl/Model/ShortUrlCreation.php | 8 +++-- .../Model/Validation/CustomSlugFilter.php | 34 +++++++++++++++++++ .../Model/Validation/ShortUrlInputFilter.php | 26 +++++++------- .../Repository/ShortUrlRepository.php | 2 +- .../test/ShortUrl/Entity/ShortUrlTest.php | 3 +- .../ShortUrl/Model/ShortUrlCreationTest.php | 11 ++++-- .../Action/ShortUrl/CreateShortUrlAction.php | 4 +-- .../SingleStepCreateShortUrlAction.php | 2 +- 13 files changed, 76 insertions(+), 30 deletions(-) create mode 100644 module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php diff --git a/composer.json b/composer.json index 6a279051..1b25ba49 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", - "shlinkio/shlink-installer": "dev-develop#5fcee9b as 8.3", + "shlinkio/shlink-installer": "dev-develop#7f6fce7 as 8.3", "shlinkio/shlink-ip-geolocation": "^3.2", "spiral/roadrunner": "^2.11", "spiral/roadrunner-jobs": "^2.5", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index fbc5fa03..029a50d6 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -45,6 +45,7 @@ return [ Option\UrlShortener\AppendExtraPathConfigOption::class, Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, Option\UrlShortener\EnableTrailingSlashConfigOption::class, + Option\UrlShortener\ShortUrlModeConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 46a1cc8f..6fb1001b 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -175,8 +174,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled, - ], $this->options->mode)); + ], $this->options)); $io->writeln([ sprintf('Processed long URL: %s', $longUrl), diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 827988fa..98597bad 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -21,4 +21,9 @@ final class UrlShortenerOptions public readonly ShortUrlMode $mode = ShortUrlMode::STRICT, ) { } + + public function isLooselyMode(): bool + { + return $this->mode === ShortUrlMode::LOOSELY; + } } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e3d6544c..0328923a 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -63,6 +63,9 @@ class ShortUrl extends AbstractEntity { } + /** + * @internal + */ public static function createFake(): self { return self::withLongUrl('foo'); @@ -70,6 +73,7 @@ class ShortUrl extends AbstractEntity /** * @param non-empty-string $longUrl + * @internal */ public static function withLongUrl(string $longUrl): self { diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index d0d9b279..43b39874 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -48,9 +49,10 @@ final class ShortUrlCreation implements TitleResolutionModelInterface /** * @throws ValidationException */ - public static function fromRawData(array $data, ShortUrlMode $mode = ShortUrlMode::STRICT): self + public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self { - $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); + $options = $options ?? new UrlShortenerOptions(); + $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } @@ -61,7 +63,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - shortUrlMode: $mode, + shortUrlMode: $options->mode, deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php new file mode 100644 index 00000000..8355a003 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -0,0 +1,34 @@ +options->isLooselyMode() ? strtolower($value) : $value; + if ($this->options->multiSegmentSlugsEnabled) { + return trim(str_replace(' ', '-', $value), '/'); + } + + return str_replace([' ', '/'], '-', $value); + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 020cdfd2..9c10d3ff 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -9,16 +9,17 @@ use Laminas\Filter; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; -use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function is_string; -use function str_replace; use function substr; -use function trim; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; +/** + * @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl. + * Make it also dynamically add the relevant fields + */ class ShortUrlInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -40,23 +41,23 @@ class ShortUrlInputFilter extends InputFilter public const CRAWLABLE = 'crawlable'; public const FORWARD_QUERY = 'forwardQuery'; - private function __construct(array $data, bool $requireLongUrl) + private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options) { - $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); + $this->initialize($requireLongUrl, $options); $this->setData($data); } - public static function withRequiredLongUrl(array $data): self + public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self { - return new self($data, true); + return new self($data, true, $options); } public static function withNonRequiredLongUrl(array $data): self { - return new self($data, false); + return new self($data, false, new UrlShortenerOptions()); } - private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void + private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void { $longUrlNotEmptyCommonOptions = [ Validator\NotEmpty::OBJECT, @@ -93,10 +94,7 @@ class ShortUrlInputFilter extends InputFilter // 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 $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); - $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { - true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, - false => static fn (mixed $v) => is_string($v) ? str_replace([' ', '/'], '-', $v) : $v, - })); + $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, Validator\NotEmpty::SPACE, diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index f8e384e2..05800abd 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -36,7 +36,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode)) ->andWhere($qb->expr()->orX( $qb->expr()->isNull('s.domain'), - $qb->expr()->eq('d.authority', ':domain') + $qb->expr()->eq('d.authority', ':domain'), )) ->setParameter('domain', $identifier->domain); diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index be1747fd..b69b369a 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -144,7 +145,7 @@ class ShortUrlTest extends TestCase $allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( [ShortUrlInputFilter::LONG_URL => 'foo'], - $mode, + new UrlShortenerOptions(mode: $mode), )); $shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 4d11289c..9582180b 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -6,10 +6,11 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use stdClass; @@ -114,13 +115,13 @@ class ShortUrlCreationTest extends TestCase string $customSlug, string $expectedSlug, bool $multiSegmentEnabled = false, + ShortUrlMode $shortUrlMode = ShortUrlMode::STRICT, ): void { $creation = ShortUrlCreation::fromRawData([ 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug, 'longUrl' => 'longUrl', - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, - ]); + ], new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentEnabled, mode: $shortUrlMode)); self::assertTrue($creation->hasValidSince()); self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince); @@ -139,16 +140,20 @@ class ShortUrlCreationTest extends TestCase { yield ['πŸ”₯', 'πŸ”₯']; yield ['🦣 πŸ…', '🦣-πŸ…']; + yield ['🦣 πŸ…', '🦣-πŸ…', false, ShortUrlMode::LOOSELY]; yield ['foobar', 'foobar']; yield ['foo bar', 'foo-bar']; yield ['foo bar baz', 'foo-bar-baz']; yield ['foo bar-baz', 'foo-bar-baz']; + yield ['foo BAR-baz', 'foo-bar-baz', false, ShortUrlMode::LOOSELY]; yield ['foo/bar/baz', 'foo/bar/baz', true]; yield ['/foo/bar/baz', 'foo/bar/baz', true]; + yield ['/foo/baR/baZ', 'foo/bar/baz', true, ShortUrlMode::LOOSELY]; yield ['foo/bar/baz', 'foo-bar-baz']; yield ['/foo/bar/baz', '-foo-bar-baz']; yield ['wp-admin.php', 'wp-admin.php']; yield ['UPPER_lower', 'UPPER_lower']; + yield ['UPPER_lower', 'upper_lower', false, ShortUrlMode::LOOSELY]; yield ['more~url_special.chars', 'more~url_special.chars']; yield ['ꡬ글', 'ꡬ글']; yield ['グーグル', 'グーグル']; diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index c7b6b33f..67509f1b 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -23,8 +22,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction { $payload = (array) $request->getParsedBody(); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); - $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled; - return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions->mode); + return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index b32e8a5d..d7f5a360 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -25,6 +25,6 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction ShortUrlInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), - ], $this->urlShortenerOptions->mode); + ], $this->urlShortenerOptions); } } From 3a149c9edc433c4de002bca561d99b2de8a5cf79 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Jan 2023 10:09:54 +0100 Subject: [PATCH 56/59] Update changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5384703e..bd0c8222 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.5.0] - 2023-01-28 ### Added * [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type. @@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method. * [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`. +* [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs. + + In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or `loosely`. + + Default value is `strict`, but if `loosely` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only. ### Changed * *Nothing* From 99c1a59dd45857a895359189afbb8f00365c7e76 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Jan 2023 10:16:53 +0100 Subject: [PATCH 57/59] Refactor CustomSlugFilter for simplicity --- .../src/ShortUrl/Model/Validation/CustomSlugFilter.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php index 8355a003..ec0b30d3 100644 --- a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -25,10 +25,9 @@ class CustomSlugFilter implements FilterInterface } $value = $this->options->isLooselyMode() ? strtolower($value) : $value; - if ($this->options->multiSegmentSlugsEnabled) { - return trim(str_replace(' ', '-', $value), '/'); - } - - return str_replace([' ', '/'], '-', $value); + return (match ($this->options->multiSegmentSlugsEnabled) { + true => trim(str_replace(' ', '-', $value), '/'), + false => str_replace([' ', '/'], '-', $value), + }); } } From 621f18bf40063db42725307bd7a05c6ba7529566 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Jan 2023 10:20:57 +0100 Subject: [PATCH 58/59] Recover DB test only for platforms in which it passes --- .../ShortUrl/Repository/ShortUrlRepositoryTest.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index b5633ee6..dd0cc4f0 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; use Cake\Chronos\Chronos; +use Doctrine\DBAL\Platforms\SQLServerPlatform; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; @@ -60,10 +61,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ShortUrlIdentifier::fromShortCodeAndDomain('fOo'), ShortUrlMode::LOOSELY, )); -// self::assertNull($this->repo->findOneWithDomainFallback( // TODO MS is doing loosely checks always -// ShortUrlIdentifier::fromShortCodeAndDomain('foo'), -// ShortUrlMode::STRICT, -// )); + // TODO MS is doing loosely checks always, making this fail. + if (! $this->getEntityManager()->getConnection()->getDatabasePlatform() instanceof SQLServerPlatform) { + self::assertNull($this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + ShortUrlMode::STRICT, + )); + } self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()), ShortUrlMode::STRICT, From 587bbfdd7319dafb684a2b50e79f8a915f2ffb0e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Jan 2023 10:48:34 +0100 Subject: [PATCH 59/59] Add SemVer-compliant constraints for shlink libs --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 1b25ba49..ef47eced 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.5", - "shlinkio/shlink-common": "dev-main#61d26e7 as 5.3", - "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", + "shlinkio/shlink-common": "^5.3", + "shlinkio/shlink-config": "^2.4", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", - "shlinkio/shlink-installer": "dev-develop#7f6fce7 as 8.3", + "shlinkio/shlink-installer": "^8.3", "shlinkio/shlink-ip-geolocation": "^3.2", "spiral/roadrunner": "^2.11", "spiral/roadrunner-jobs": "^2.5",