From ab8fa52ca4df808e702be9869c3b9ccb5a237987 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 18:15:05 +0100 Subject: [PATCH 01/64] Modernize Domain entity --- module/Core/src/Domain/Entity/Domain.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index 4e6ea865..b3d2b734 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -11,12 +11,12 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface { - private ?string $baseUrlRedirect = null; - private ?string $regular404Redirect = null; - private ?string $invalidShortUrlRedirect = null; - - private function __construct(public readonly string $authority) - { + private function __construct( + public readonly string $authority, + private ?string $baseUrlRedirect = null, + private ?string $regular404Redirect = null, + private ?string $invalidShortUrlRedirect = null, + ) { } public static function withAuthority(string $authority): self From 60e9443b1203bf98d04deaddb97dc3f2d94ef370 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 18:33:56 +0100 Subject: [PATCH 02/64] Modernize ApiKey entity --- .../CLI/src/Command/Api/ListKeysCommand.php | 4 +-- .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- module/Rest/src/Entity/ApiKey.php | 35 +++++-------------- .../Rest/test/Service/ApiKeyServiceTest.php | 4 +-- 4 files changed, 14 insertions(+), 31 deletions(-) diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index fab02087..40ae8eef 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -50,11 +50,11 @@ class ListKeysCommand extends Command $enabledOnly = $input->getOption('enabled-only'); $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) { - $expiration = $apiKey->getExpirationDate(); + $expiration = $apiKey->expirationDate; $messagePattern = $this->determineMessagePattern($apiKey); // Set columns for this row - $rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name() ?? '-')]; + $rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name ?? '-')]; if (! $enabledOnly) { $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index a318e6e4..ffcffd8b 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -233,7 +233,7 @@ class ListShortUrlsCommand extends Command } if ($input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => - $shortUrl->authorApiKey()?->name(); + $shortUrl->authorApiKey()?->name; } return $columnsMap; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 9ad3fcf4..46548dcf 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -17,21 +17,17 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKey extends AbstractEntity { - private string $key; - private ?Chronos $expirationDate = null; - private bool $enabled; - /** @var Collection */ - private Collection $roles; - private ?string $name = null; - /** + * @param Collection $roles * @throws Exception */ - private function __construct(string $key) - { - $this->key = $key; - $this->enabled = true; - $this->roles = new ArrayCollection(); + private function __construct( + private string $key, + public readonly ?string $name = null, + public readonly ?Chronos $expirationDate = null, + private bool $enabled = true, + private Collection $roles = new ArrayCollection(), + ) { } /** @@ -47,10 +43,7 @@ class ApiKey extends AbstractEntity */ public static function fromMeta(ApiKeyMeta $meta): self { - $apiKey = new self($meta->key); - $apiKey->name = $meta->name; - $apiKey->expirationDate = $meta->expirationDate; - + $apiKey = new self($meta->key, $meta->name, $meta->expirationDate); foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); } @@ -58,21 +51,11 @@ class ApiKey extends AbstractEntity return $apiKey; } - public function getExpirationDate(): ?Chronos - { - return $this->expirationDate; - } - public function isExpired(): bool { return $this->expirationDate !== null && $this->expirationDate->lessThan(Chronos::now()); } - public function name(): ?string - { - return $this->name; - } - public function isEnabled(): bool { return $this->enabled; diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index f45e6ca5..45364070 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -44,8 +44,8 @@ class ApiKeyServiceTest extends TestCase ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles), ); - self::assertEquals($date, $key->getExpirationDate()); - self::assertEquals($name, $key->name()); + self::assertEquals($date, $key->expirationDate); + self::assertEquals($name, $key->name); foreach ($roles as $roleDefinition) { self::assertTrue($key->hasRole($roleDefinition->role)); } From b2dee43bb0d80e4215cb595c8340021e477fae16 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 19:11:42 +0100 Subject: [PATCH 03/64] Modernize VisitLocation entity --- .../Visit/AbstractVisitsListCommand.php | 4 +- .../src/Command/Visit/LocateVisitsCommand.php | 4 +- .../Matomo/SendVisitToMatomo.php | 8 +- .../Core/src/Visit/Entity/VisitLocation.php | 107 ++++++------------ .../test/Visit/Entity/VisitLocationTest.php | 2 +- 5 files changed, 45 insertions(+), 80 deletions(-) diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index bd20a4ae..a999760e 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -55,8 +55,8 @@ abstract class AbstractVisitsListCommand extends Command $rowData = [ ...$visit->jsonSerialize(), - 'country' => $visit->getVisitLocation()?->getCountryName() ?? 'Unknown', - 'city' => $visit->getVisitLocation()?->getCityName() ?? 'Unknown', + 'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown', + 'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown', ...$extraFields, ]; diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 09e53556..9d8d6674 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -154,8 +154,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void { - if (! $visitLocation->isEmpty()) { - $this->io->writeln(sprintf(' [Address located in "%s"]', $visitLocation->getCountryName())); + if (! $visitLocation->isEmpty) { + $this->io->writeln(sprintf(' [Address located in "%s"]', $visitLocation->countryName)); } elseif ($visit->hasRemoteAddr() && $visit->getRemoteAddr() !== IpAddress::LOCALHOST) { $this->io->writeln(' [Could not locate address]'); } diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index ad9660cb..ae1514e4 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -53,10 +53,10 @@ class SendVisitToMatomo $location = $visit->getVisitLocation(); if ($location !== null) { $tracker - ->setCity($location->getCityName()) - ->setCountry($location->getCountryName()) - ->setLatitude($location->getLatitude()) - ->setLongitude($location->getLongitude()); + ->setCity($location->cityName) + ->setCountry($location->countryName) + ->setLatitude($location->latitude) + ->setLongitude($location->longitude); } // Set not obfuscated IP if possible, as matomo handles obfuscation itself diff --git a/module/Core/src/Visit/Entity/VisitLocation.php b/module/Core/src/Visit/Entity/VisitLocation.php index 2b3b854e..1f1db686 100644 --- a/module/Core/src/Visit/Entity/VisitLocation.php +++ b/module/Core/src/Visit/Entity/VisitLocation.php @@ -11,89 +11,54 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocation extends AbstractEntity implements JsonSerializable { - private string $countryCode; - private string $countryName; - private string $regionName; - private string $cityName; - private float $latitude; - private float $longitude; - private string $timezone; - private bool $isEmpty; + public readonly bool $isEmpty; - private function __construct() - { + private function __construct( + public readonly string $countryCode, + public readonly string $countryName, + public readonly string $regionName, + public readonly string $cityName, + public readonly float $latitude, + public readonly float $longitude, + public readonly string $timezone, + ) { + $this->isEmpty = ( + $countryCode === '' && + $countryName === '' && + $regionName === '' && + $cityName === '' && + $latitude === 0.0 && + $longitude === 0.0 && + $timezone === '' + ); } public static function fromGeolocation(Location $location): self { - $instance = new self(); - - $instance->countryCode = $location->countryCode; - $instance->countryName = $location->countryName; - $instance->regionName = $location->regionName; - $instance->cityName = $location->city; - $instance->latitude = $location->latitude; - $instance->longitude = $location->longitude; - $instance->timezone = $location->timeZone; - $instance->computeIsEmpty(); - - return $instance; + return new self( + countryCode: $location->countryCode, + countryName: $location->countryName, + regionName: $location->regionName, + cityName: $location->city, + latitude: $location->latitude, + longitude: $location->longitude, + timezone: $location->timeZone, + ); } public static function fromImport(ImportedShlinkVisitLocation $location): self { - $instance = new self(); - - $instance->countryCode = $location->countryCode; - $instance->countryName = $location->countryName; - $instance->regionName = $location->regionName; - $instance->cityName = $location->cityName; - $instance->latitude = $location->latitude; - $instance->longitude = $location->longitude; - $instance->timezone = $location->timezone; - $instance->computeIsEmpty(); - - return $instance; - } - - private function computeIsEmpty(): void - { - $this->isEmpty = ( - $this->countryCode === '' && - $this->countryName === '' && - $this->regionName === '' && - $this->cityName === '' && - $this->latitude === 0.0 && - $this->longitude === 0.0 && - $this->timezone === '' + return new self( + countryCode: $location->countryCode, + countryName: $location->countryName, + regionName: $location->regionName, + cityName: $location->cityName, + latitude: $location->latitude, + longitude: $location->longitude, + timezone: $location->timezone, ); } - public function getCountryName(): string - { - return $this->countryName; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } - - public function getCityName(): string - { - return $this->cityName; - } - - public function isEmpty(): bool - { - return $this->isEmpty; - } - public function jsonSerialize(): array { return [ diff --git a/module/Core/test/Visit/Entity/VisitLocationTest.php b/module/Core/test/Visit/Entity/VisitLocationTest.php index 46400f70..5f5c458d 100644 --- a/module/Core/test/Visit/Entity/VisitLocationTest.php +++ b/module/Core/test/Visit/Entity/VisitLocationTest.php @@ -18,7 +18,7 @@ class VisitLocationTest extends TestCase $payload = new Location(...$args); $location = VisitLocation::fromGeolocation($payload); - self::assertEquals($isEmpty, $location->isEmpty()); + self::assertEquals($isEmpty, $location->isEmpty); } public static function provideArgs(): iterable From 78526fb4052f95cf21c70b3c0b155e325d64e41f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 19:57:30 +0100 Subject: [PATCH 04/64] Modernize Visit entity --- .../Command/Domain/GetDomainVisitsCommand.php | 2 +- .../src/Command/Tag/GetTagVisitsCommand.php | 2 +- .../Visit/GetNonOrphanVisitsCommand.php | 2 +- .../Command/Visit/GetOrphanVisitsCommand.php | 2 +- .../src/Command/Visit/LocateVisitsCommand.php | 4 +- .../Core/src/EventDispatcher/LocateVisit.php | 2 +- .../Matomo/SendVisitToMatomo.php | 12 +- .../PublishingUpdatesGenerator.php | 4 +- module/Core/src/Visit/Entity/Visit.php | 126 ++++++------------ .../Geolocation/VisitToLocationHelper.php | 2 +- .../OrphanVisitDataTransformer.php | 4 +- .../test/EventDispatcher/LocateVisitTest.php | 2 +- .../Matomo/SendVisitToMatomoTest.php | 4 +- .../PublishingUpdatesGeneratorTest.php | 4 +- .../Importer/ImportedLinksProcessorTest.php | 2 +- module/Core/test/Visit/Entity/VisitTest.php | 2 +- 16 files changed, 68 insertions(+), 108 deletions(-) diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index 8d2eb8c9..dd3797f8 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -44,7 +44,7 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand */ protected function mapExtraFields(Visit $visit): array { - $shortUrl = $visit->getShortUrl(); + $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; } } diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index 290a172a..1dfd0ba9 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -44,7 +44,7 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand */ protected function mapExtraFields(Visit $visit): array { - $shortUrl = $visit->getShortUrl(); + $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; } } diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 0dd32f3e..1462620d 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand */ protected function mapExtraFields(Visit $visit): array { - $shortUrl = $visit->getShortUrl(); + $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; } } diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 7beae19a..d495db77 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -42,6 +42,6 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand */ protected function mapExtraFields(Visit $visit): array { - return ['type' => $visit->type()->value]; + return ['type' => $visit->type->value]; } } diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 9d8d6674..596b287e 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -132,7 +132,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat */ public function geolocateVisit(Visit $visit): Location { - $ipAddr = $visit->getRemoteAddr() ?? '?'; + $ipAddr = $visit->remoteAddr ?? '?'; $this->io->write(sprintf('Processing IP %s', $ipAddr)); try { @@ -156,7 +156,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat { if (! $visitLocation->isEmpty) { $this->io->writeln(sprintf(' [Address located in "%s"]', $visitLocation->countryName)); - } elseif ($visit->hasRemoteAddr() && $visit->getRemoteAddr() !== IpAddress::LOCALHOST) { + } elseif ($visit->hasRemoteAddr() && $visit->remoteAddr !== IpAddress::LOCALHOST) { $this->io->writeln(' [Could not locate address]'); } } diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index f139c0f5..aa6afed8 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -55,7 +55,7 @@ class LocateVisit } $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); - $addr = $originalIpAddress ?? $visit->getRemoteAddr() ?? ''; + $addr = $originalIpAddress ?? $visit->remoteAddr ?? ''; try { $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index ae1514e4..be288fd0 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -46,9 +46,9 @@ class SendVisitToMatomo $tracker ->setUrl($this->resolveUrlToTrack($visit)) - ->setCustomTrackingParameter('type', $visit->type()->value) - ->setUserAgent($visit->userAgent()) - ->setUrlReferrer($visit->referer()); + ->setCustomTrackingParameter('type', $visit->type->value) + ->setUserAgent($visit->userAgent) + ->setUrlReferrer($visit->referer); $location = $visit->getVisitLocation(); if ($location !== null) { @@ -60,7 +60,7 @@ class SendVisitToMatomo } // Set not obfuscated IP if possible, as matomo handles obfuscation itself - $ip = $visitLocated->originalIpAddress ?? $visit->getRemoteAddr(); + $ip = $visitLocated->originalIpAddress ?? $visit->remoteAddr; if ($ip !== null) { $tracker->setIp($ip); } @@ -79,9 +79,9 @@ class SendVisitToMatomo public function resolveUrlToTrack(Visit $visit): string { - $shortUrl = $visit->getShortUrl(); + $shortUrl = $visit->shortUrl; if ($shortUrl === null) { - return $visit->visitedUrl() ?? ''; + return $visit->visitedUrl ?? ''; } return $this->shortUrlStringifier->stringify($shortUrl); diff --git a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php index 9acdfd04..06d06c84 100644 --- a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -20,7 +20,7 @@ final class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInte public function newVisitUpdate(Visit $visit): Update { return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [ - 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), + 'shortUrl' => $this->shortUrlTransformer->transform($visit->shortUrl), 'visit' => $visit->jsonSerialize(), ]); } @@ -34,7 +34,7 @@ final class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInte public function newShortUrlVisitUpdate(Visit $visit): Update { - $shortUrl = $visit->getShortUrl(); + $shortUrl = $visit->shortUrl; $topic = Topic::newShortUrlVisit($shortUrl?->getShortCode()); return Update::forTopicAndPayload($topic, [ diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 255a55f4..0302f898 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -20,29 +20,22 @@ use function Shlinkio\Shlink\Core\normalizeDate; class Visit extends AbstractEntity implements JsonSerializable { - private string $referer; - private Chronos $date; - private ?string $remoteAddr = null; - private ?string $visitedUrl = null; - private string $userAgent; - private VisitType $type; - private ?ShortUrl $shortUrl; - private ?VisitLocation $visitLocation = null; - private bool $potentialBot; - - private function __construct(?ShortUrl $shortUrl, VisitType $type) - { - $this->shortUrl = $shortUrl; - $this->date = Chronos::now(); - $this->type = $type; + private function __construct( + public readonly ?ShortUrl $shortUrl, + public readonly VisitType $type, + public readonly string $userAgent, + public readonly string $referer, + private readonly bool $potentialBot, + public readonly ?string $remoteAddr = null, + public readonly ?string $visitedUrl = null, + private ?VisitLocation $visitLocation = null, + private Chronos $date = new Chronos(), + ) { } public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self { - $instance = new self($shortUrl, VisitType::VALID_SHORT_URL); - $instance->hydrateFromVisitor($visitor, $anonymize); - - return $instance; + return self::hydrateFromVisitor($shortUrl, VisitType::VALID_SHORT_URL, $visitor, $anonymize); } public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self @@ -52,13 +45,10 @@ class Visit extends AbstractEntity implements JsonSerializable public static function fromOrphanImport(ImportedShlinkOrphanVisit $importedVisit): self { - $instance = self::fromImportOrOrphanImport( + return self::fromImportOrOrphanImport( $importedVisit, VisitType::tryFrom($importedVisit->type) ?? VisitType::IMPORTED, ); - $instance->visitedUrl = $importedVisit->visitedUrl; - - return $instance; } private static function fromImportOrOrphanImport( @@ -66,52 +56,52 @@ class Visit extends AbstractEntity implements JsonSerializable VisitType $type, ?ShortUrl $shortUrl = null, ): self { - $instance = new self($shortUrl, $type); - $instance->userAgent = $importedVisit->userAgent; - $instance->potentialBot = isCrawler($instance->userAgent); - $instance->referer = $importedVisit->referer; - $instance->date = normalizeDate($importedVisit->date); - $importedLocation = $importedVisit->location; - $instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null; - - return $instance; + return new self( + shortUrl: $shortUrl, + type: $type, + userAgent: $importedVisit->userAgent, + referer: $importedVisit->referer, + potentialBot: isCrawler($importedVisit->userAgent), + visitedUrl: $importedVisit instanceof ImportedShlinkOrphanVisit ? $importedVisit->visitedUrl : null, + visitLocation: $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null, + date: normalizeDate($importedVisit->date), + ); } public static function forBasePath(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, VisitType::BASE_URL); - $instance->hydrateFromVisitor($visitor, $anonymize); - - return $instance; + return self::hydrateFromVisitor(null, VisitType::BASE_URL, $visitor, $anonymize); } public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, VisitType::INVALID_SHORT_URL); - $instance->hydrateFromVisitor($visitor, $anonymize); - - return $instance; + return self::hydrateFromVisitor(null, VisitType::INVALID_SHORT_URL, $visitor, $anonymize); } public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, VisitType::REGULAR_404); - $instance->hydrateFromVisitor($visitor, $anonymize); - - return $instance; + return self::hydrateFromVisitor(null, VisitType::REGULAR_404, $visitor, $anonymize); } - private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void - { - $this->userAgent = $visitor->userAgent; - $this->referer = $visitor->referer; - $this->remoteAddr = $this->processAddress($anonymize, $visitor->remoteAddress); - $this->visitedUrl = $visitor->visitedUrl; - $this->potentialBot = $visitor->isPotentialBot(); + private static function hydrateFromVisitor( + ?ShortUrl $shortUrl, + VisitType $type, + Visitor $visitor, + bool $anonymize, + ): self { + return new self( + shortUrl: $shortUrl, + type: $type, + userAgent: $visitor->userAgent, + referer: $visitor->referer, + potentialBot: $visitor->isPotentialBot(), + remoteAddr: self::processAddress($anonymize, $visitor->remoteAddress), + visitedUrl: $visitor->visitedUrl, + ); } - private function processAddress(bool $anonymize, ?string $address): ?string + private static function processAddress(bool $anonymize, ?string $address): ?string { // Localhost addresses do not need to be anonymized if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) { @@ -125,21 +115,11 @@ class Visit extends AbstractEntity implements JsonSerializable } } - public function getRemoteAddr(): ?string - { - return $this->remoteAddr; - } - public function hasRemoteAddr(): bool { return ! empty($this->remoteAddr); } - public function getShortUrl(): ?ShortUrl - { - return $this->shortUrl; - } - public function getVisitLocation(): ?VisitLocation { return $this->visitLocation; @@ -161,23 +141,13 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->shortUrl === null; } - public function visitedUrl(): ?string - { - return $this->visitedUrl; - } - - public function type(): VisitType - { - return $this->type; - } - /** * Needed only for ArrayCollections to be able to apply criteria filtering * @internal */ public function getType(): VisitType { - return $this->type(); + return $this->type; } /** @@ -188,16 +158,6 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->date; } - public function userAgent(): string - { - return $this->userAgent; - } - - public function referer(): string - { - return $this->referer; - } - public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php b/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php index 2a261019..9d614a7b 100644 --- a/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php +++ b/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php @@ -26,7 +26,7 @@ class VisitToLocationHelper implements VisitToLocationHelperInterface throw IpCannotBeLocatedException::forEmptyAddress(); } - $ipAddr = $visit->getRemoteAddr() ?? ''; + $ipAddr = $visit->remoteAddr ?? ''; if ($ipAddr === IpAddress::LOCALHOST) { throw IpCannotBeLocatedException::forLocalhost(); } diff --git a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php index cf1c8bc9..c4dd6253 100644 --- a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php +++ b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php @@ -15,8 +15,8 @@ class OrphanVisitDataTransformer implements DataTransformerInterface public function transform($visit): array // phpcs:ignore { $serializedVisit = $visit->jsonSerialize(); - $serializedVisit['visitedUrl'] = $visit->visitedUrl(); - $serializedVisit['type'] = $visit->type()->value; + $serializedVisit['visitedUrl'] = $visit->visitedUrl; + $serializedVisit['type'] = $visit->type->value; return $serializedVisit; } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index ddadde84..63595a6c 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -157,7 +157,7 @@ class LocateVisitTest extends TestCase #[Test, DataProvider('provideIpAddresses')] public function locatableVisitsResolveToLocation(Visit $visit, ?string $originalIpAddress): void { - $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); + $ipAddr = $originalIpAddress ?? $visit->remoteAddr; $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new UrlVisited('123', $originalIpAddress); diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index 94c66623..d821bcbb 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -76,13 +76,13 @@ class SendVisitToMatomoTest extends TestCase if ($visit->isOrphan()) { $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ - ['type', $visit->type()->value, $tracker], + ['type', $visit->type->value, $tracker], ['orphan', 'true', $tracker], ]); } else { $tracker->expects($this->once())->method('setCustomTrackingParameter')->with( 'type', - $visit->type()->value, + $visit->type->value, )->willReturn($tracker); } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 545c5b47..75faa25e 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -93,8 +93,8 @@ class PublishingUpdatesGeneratorTest extends TestCase 'visitLocation' => null, 'date' => $orphanVisit->getDate()->toAtomString(), 'potentialBot' => false, - 'visitedUrl' => $orphanVisit->visitedUrl(), - 'type' => $orphanVisit->type()->value, + 'visitedUrl' => $orphanVisit->visitedUrl, + 'type' => $orphanVisit->type->value, ], ], $update->payload); } diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index 7c8a17d1..a1816563 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -244,7 +244,7 @@ class ImportedLinksProcessorTest extends TestCase $this->em->expects($this->once())->method('persist')->willReturnCallback( static fn (Visit $visit) => Assert::assertSame( $foundShortUrl ?? $originalShortUrl, - $visit->getShortUrl(), + $visit->shortUrl, ), ); diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 5eb88527..3fea2882 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -49,7 +49,7 @@ class VisitTest extends TestCase $anonymize, ); - self::assertEquals($expectedAddress, $visit->getRemoteAddr()); + self::assertEquals($expectedAddress, $visit->remoteAddr); } public static function provideAddresses(): iterable From 5524476787173c3fc1fd27ac5359a69ca1000ec1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 20:21:26 +0100 Subject: [PATCH 05/64] Modernize ShortUrl entity --- .../Command/ShortUrl/ListShortUrlsCommand.php | 4 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 98 +++++++++---------- 2 files changed, 48 insertions(+), 54 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index ffcffd8b..c4346f14 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -229,11 +229,11 @@ class ListShortUrlsCommand extends Command } if ($input->getOption('show-api-key')) { $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->authorApiKey()?->__toString() ?? ''; + $shortUrl->authorApiKey?->__toString() ?? ''; } if ($input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => - $shortUrl->authorApiKey()?->name; + $shortUrl->authorApiKey?->name; } return $columnsMap; diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 8a577205..ef703e54 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -32,29 +32,30 @@ use function sprintf; class ShortUrl extends AbstractEntity { - private string $longUrl; - private string $shortCode; - private Chronos $dateCreated; - /** @var Collection & Selectable */ - private Collection & Selectable $visits; - /** @var Collection */ - private Collection $tags; - private ?Chronos $validSince = null; - private ?Chronos $validUntil = null; - private ?int $maxVisits = null; - private ?Domain $domain = null; - private bool $customSlugWasProvided; - private int $shortCodeLength; - private ?string $importSource = null; - private ?string $importOriginalShortCode = null; - private ?ApiKey $authorApiKey = null; - private ?string $title = null; - private bool $titleWasAutoResolved = false; - private bool $crawlable = false; - private bool $forwardQuery = true; - - private function __construct() - { + /** + * @param Collection $tags + * @param Collection & Selectable $visits + */ + private function __construct( + private string $longUrl, + private string $shortCode, + private Chronos $dateCreated = new Chronos(), + private Collection $tags = new ArrayCollection(), + private Collection & Selectable $visits = new ArrayCollection(), + private ?Chronos $validSince = null, + private ?Chronos $validUntil = null, + private ?int $maxVisits = null, + private ?Domain $domain = null, + private bool $customSlugWasProvided = false, + private int $shortCodeLength = 0, + public readonly ?ApiKey $authorApiKey = null, + private ?string $title = null, + private bool $titleWasAutoResolved = false, + private bool $crawlable = false, + private bool $forwardQuery = true, + private ?string $importSource = null, + private ?string $importOriginalShortCode = null, + ) { } /** @@ -78,31 +79,29 @@ class ShortUrl extends AbstractEntity ShortUrlCreation $creation, ?ShortUrlRelationResolverInterface $relationResolver = null, ): self { - $instance = new self(); $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); + $shortCodeLength = $creation->shortCodeLength; - $instance->longUrl = $creation->getLongUrl(); - $instance->dateCreated = Chronos::now(); - $instance->visits = new ArrayCollection(); - $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->shortCodeLength; - $instance->shortCode = sprintf( - '%s%s', - $creation->pathPrefix ?? '', - $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength, $creation->shortUrlMode), + return new self( + longUrl: $creation->getLongUrl(), + shortCode: sprintf( + '%s%s', + $creation->pathPrefix ?? '', + $creation->customSlug ?? generateRandomShortCode($shortCodeLength, $creation->shortUrlMode), + ), + tags: $relationResolver->resolveTags($creation->tags), + validSince: $creation->validSince, + validUntil: $creation->validUntil, + maxVisits: $creation->maxVisits, + domain: $relationResolver->resolveDomain($creation->domain), + customSlugWasProvided: $creation->hasCustomSlug(), + shortCodeLength: $shortCodeLength, + authorApiKey: $creation->apiKey, + title: $creation->title, + titleWasAutoResolved: $creation->titleWasAutoResolved, + crawlable: $creation->crawlable, + forwardQuery: $creation->forwardQuery, ); - $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; } public static function fromImport( @@ -123,11 +122,11 @@ class ShortUrl extends AbstractEntity $instance = self::create(ShortUrlCreation::fromRawData($meta), $relationResolver); - $instance->importSource = $url->source->value; - $instance->importOriginalShortCode = $url->shortCode; $instance->validSince = normalizeOptionalDate($url->meta->validSince); $instance->validUntil = normalizeOptionalDate($url->meta->validUntil); $instance->dateCreated = normalizeDate($url->createdAt); + $instance->importSource = $url->source->value; + $instance->importOriginalShortCode = $url->shortCode; return $instance; } @@ -196,11 +195,6 @@ class ShortUrl extends AbstractEntity return $this->tags; } - public function authorApiKey(): ?ApiKey - { - return $this->authorApiKey; - } - public function getValidSince(): ?Chronos { return $this->validSince; From cd387328be510d90bcd97562cbc580832396b682 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 20:22:54 +0100 Subject: [PATCH 06/64] Update changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e646df82..7c58d8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [4.0.3] - 2024-03-15 ### Added * *Nothing* From e028d8ea316a28a72778fbd4e25f83fb267192b5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Mar 2024 22:09:15 +0100 Subject: [PATCH 07/64] Move logic to serialize ShortUrls to entity itself --- .../src/ShortUrl/DeleteShortUrlService.php | 8 +-- module/Core/src/ShortUrl/Entity/ShortUrl.php | 72 ++++++++----------- .../Transformer/ShortUrlDataTransformer.php | 35 +-------- .../PublishingUpdatesGeneratorTest.php | 2 +- .../test/ShortUrl/ShortUrlServiceTest.php | 8 ++- 5 files changed, 42 insertions(+), 83 deletions(-) diff --git a/module/Core/src/ShortUrl/DeleteShortUrlService.php b/module/Core/src/ShortUrl/DeleteShortUrlService.php index 16fe6ac0..2a39e695 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlService.php @@ -43,10 +43,8 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface private function isThresholdReached(ShortUrl $shortUrl): bool { - if (! $this->deleteShortUrlsOptions->checkVisitsThreshold) { - return false; - } - - return $shortUrl->getVisitsCount() >= $this->deleteShortUrlsOptions->visitsThreshold; + return $this->deleteShortUrlsOptions->checkVisitsThreshold && $shortUrl->reachedVisits( + $this->deleteShortUrlsOptions->visitsThreshold, + ); } } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index ef703e54..474d5afc 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -20,10 +20,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function array_map; use function count; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; @@ -187,33 +189,9 @@ class ShortUrl extends AbstractEntity return $this->domain; } - /** - * @return Collection - */ - public function getTags(): Collection + public function reachedVisits(int $visitsAmount): bool { - return $this->tags; - } - - public function getValidSince(): ?Chronos - { - return $this->validSince; - } - - public function getValidUntil(): ?Chronos - { - return $this->validUntil; - } - - public function getVisitsCount(): int - { - return count($this->visits); - } - - public function nonBotVisitsCount(): int - { - $criteria = Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)); - return count($this->visits->matching($criteria)); + return count($this->visits) >= $visitsAmount; } public function mostRecentImportedVisitDate(): ?Chronos @@ -236,21 +214,6 @@ class ShortUrl extends AbstractEntity return $this; } - public function getMaxVisits(): ?int - { - return $this->maxVisits; - } - - public function title(): ?string - { - return $this->title; - } - - public function crawlable(): bool - { - return $this->crawlable; - } - public function forwardQuery(): bool { return $this->forwardQuery; @@ -276,7 +239,7 @@ class ShortUrl extends AbstractEntity public function isEnabled(): bool { - $maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits; + $maxVisitsReached = $this->maxVisits !== null && $this->reachedVisits($this->maxVisits); if ($maxVisitsReached) { return false; } @@ -294,4 +257,29 @@ class ShortUrl extends AbstractEntity return true; } + + public function toArray(): array + { + return [ + 'shortCode' => $this->shortCode, + 'longUrl' => $this->longUrl, + 'dateCreated' => $this->dateCreated->toAtomString(), + 'tags' => array_map(static fn (Tag $tag) => $tag->__toString(), $this->tags->toArray()), + 'meta' => [ + 'validSince' => $this->validSince?->toAtomString(), + 'validUntil' => $this->validUntil?->toAtomString(), + 'maxVisits' => $this->maxVisits, + ], + 'domain' => $this->domain, + 'title' => $this->title, + 'crawlable' => $this->crawlable, + 'forwardQuery' => $this->forwardQuery, + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots( + count($this->visits), + count($this->visits->matching( + Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)), + )), + ), + ]; + } } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index ea694c61..09b9436b 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,14 +7,10 @@ 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\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; -use function array_map; - -class ShortUrlDataTransformer implements DataTransformerInterface +readonly class ShortUrlDataTransformer implements DataTransformerInterface { - public function __construct(private readonly ShortUrlStringifierInterface $stringifier) + public function __construct(private ShortUrlStringifierInterface $stringifier) { } @@ -24,33 +20,8 @@ class ShortUrlDataTransformer implements DataTransformerInterface public function transform($shortUrl): array // phpcs:ignore { return [ - 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => $this->stringifier->stringify($shortUrl), - 'longUrl' => $shortUrl->getLongUrl(), - 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'tags' => array_map(static fn (Tag $tag) => $tag->__toString(), $shortUrl->getTags()->toArray()), - 'meta' => $this->buildMeta($shortUrl), - 'domain' => $shortUrl->getDomain(), - 'title' => $shortUrl->title(), - 'crawlable' => $shortUrl->crawlable(), - 'forwardQuery' => $shortUrl->forwardQuery(), - 'visitsSummary' => VisitsSummary::fromTotalAndNonBots( - $shortUrl->getVisitsCount(), - $shortUrl->nonBotVisitsCount(), - ), - ]; - } - - private function buildMeta(ShortUrl $shortUrl): array - { - $validSince = $shortUrl->getValidSince(); - $validUntil = $shortUrl->getValidUntil(); - $maxVisits = $shortUrl->getMaxVisits(); - - return [ - 'validSince' => $validSince?->toAtomString(), - 'validUntil' => $validUntil?->toAtomString(), - 'maxVisits' => $maxVisits, + ...$shortUrl->toArray(), ]; } } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 75faa25e..94802cae 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -132,7 +132,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'maxVisits' => null, ], 'domain' => null, - 'title' => $shortUrl->title(), + 'title' => 'The title', 'crawlable' => false, 'forwardQuery' => true, 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index ae73ba33..669015a0 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -70,9 +70,11 @@ 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()); + ['validSince' => $since, 'validUntil' => $until, 'maxVisits' => $maxVisits] = $shortUrl->toArray()['meta']; + + self::assertEquals($shortUrlEdit->validSince?->toAtomString(), $since); + self::assertEquals($shortUrlEdit->validUntil?->toAtomString(), $until); + self::assertEquals($shortUrlEdit->maxVisits, $maxVisits); self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl()); } From b94a22e6a703288b1b354981e5d8c633f991e6e9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Mar 2024 09:06:35 +0100 Subject: [PATCH 08/64] Rename Ordering::emptyInstance to Ordering::none to make it more clear --- module/Core/src/Model/Ordering.php | 11 ++---- .../Repository/ShortUrlListRepositoryTest.php | 38 +++++++++---------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index 5adbb161..12cf7dd5 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Model; -final class Ordering +final readonly class Ordering { private const DEFAULT_DIR = 'ASC'; - private function __construct(public readonly ?string $field, public readonly string $direction) + private function __construct(public ?string $field, public string $direction) { } @@ -21,13 +21,8 @@ final class Ordering return new self($field, $dir ?? self::DEFAULT_DIR); } - public static function emptyInstance(): self + public static function none(): self { return self::fromTuple([null, null]); } - - public function hasOrderField(): bool - { - return $this->field !== null; - } } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index b359e35d..6508b0e2 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -86,28 +86,28 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']), + new ShortUrlsListFiltering(null, null, Ordering::none(), 'foo', ['bar']), ); self::assertCount(1, $result); self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); self::assertSame($foo, $result[0]); // Assert searched text also applies to tags - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'bar')); + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::none(), 'bar')); self::assertCount(2, $result); self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); self::assertContains($foo, $result); - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance())); + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::none())); self::assertCount(3, $result); - $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance())); + $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::none())); self::assertCount(2, $result); - $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance())); + $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::none())); self::assertCount(2, $result); - self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance()))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::none()))); $result = $this->repo->findList( new ShortUrlsListFiltering(null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'DESC'])), @@ -124,7 +124,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertSame($foo2, $result[0]); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( + new ShortUrlsListFiltering(null, null, Ordering::none(), null, [], null, DateRange::until( Chronos::now()->subDays(2), )), ); @@ -135,7 +135,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertSame($foo2, $result[0]); self::assertCount(2, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( + new ShortUrlsListFiltering(null, null, Ordering::none(), null, [], null, DateRange::since( Chronos::now()->subDays(2), )), )); @@ -197,12 +197,12 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']), + new ShortUrlsListFiltering(null, null, Ordering::none(), null, ['foo', 'bar']), )); self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), null, ['foo', 'bar'], TagsMode::ANY, @@ -210,7 +210,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), null, ['foo', 'bar'], TagsMode::ALL, @@ -220,12 +220,12 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); self::assertCount(4, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), + new ShortUrlsListFiltering(null, null, Ordering::none(), null, ['bar', 'baz']), )); self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), null, ['bar', 'baz'], TagsMode::ANY, @@ -233,7 +233,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), null, ['bar', 'baz'], TagsMode::ALL, @@ -247,12 +247,12 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase )); self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']), + new ShortUrlsListFiltering(null, null, Ordering::none(), null, ['foo', 'bar', 'baz']), )); self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), null, ['foo', 'bar', 'baz'], TagsMode::ANY, @@ -260,7 +260,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), null, ['foo', 'bar', 'baz'], TagsMode::ALL, @@ -298,7 +298,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), searchTerm: $searchTerm, defaultDomain: 'deFaulT-domain.com', ); @@ -343,7 +343,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase new ShortUrlsListFiltering( null, null, - Ordering::emptyInstance(), + Ordering::none(), excludeMaxVisitsReached: $excludeMaxVisitsReached, excludePastValidUntil: $excludePastValidUntil, ); From fbd35b797405493d5942efb3e3ef12aec41868f6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Mar 2024 09:15:45 +0100 Subject: [PATCH 09/64] Add more named constructors to Ordering class --- module/Core/src/Model/Ordering.php | 16 ++++++++++-- module/Core/src/Tag/Model/TagsParams.php | 2 +- .../Repository/ShortUrlListRepositoryTest.php | 8 +++--- .../Tag/Repository/TagRepositoryTest.php | 26 +++++++------------ 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index 12cf7dd5..69a1429a 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Core\Model; final readonly class Ordering { - private const DEFAULT_DIR = 'ASC'; + private const DESC_DIR = 'DESC'; + private const ASC_DIR = 'ASC'; + private const DEFAULT_DIR = self::ASC_DIR; private function __construct(public ?string $field, public string $direction) { @@ -23,6 +25,16 @@ final readonly class Ordering public static function none(): self { - return self::fromTuple([null, null]); + return new self(null, self::DEFAULT_DIR); + } + + public static function fromFieldAsc(string $field): self + { + return new self($field, self::ASC_DIR); + } + + public static function fromFieldDesc(string $field): self + { + return new self($field, self::DESC_DIR); } } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index 422f9da1..d094bcc0 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -24,7 +24,7 @@ final class TagsParams extends AbstractInfinitePaginableListParams { return new self( $query['searchTerm'] ?? null, - Ordering::fromTuple(isset($query['orderBy']) ? parseOrderBy($query['orderBy']) : [null, null]), + isset($query['orderBy']) ? Ordering::fromTuple(parseOrderBy($query['orderBy'])) : Ordering::none(), isset($query['page']) ? (int) $query['page'] : null, isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, ); diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 6508b0e2..315491d8 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -110,15 +110,13 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::none()))); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'DESC'])), + new ShortUrlsListFiltering(null, null, Ordering::fromFieldDesc(OrderableField::VISITS->value)), ); self::assertCount(3, $result); self::assertSame($bar, $result[0]); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple( - [OrderableField::NON_BOT_VISITS->value, 'DESC'], - )), + new ShortUrlsListFiltering(null, null, Ordering::fromFieldDesc(OrderableField::NON_BOT_VISITS->value)), ); self::assertCount(3, $result); self::assertSame($foo2, $result[0]); @@ -155,7 +153,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])), + new ShortUrlsListFiltering(null, null, Ordering::fromFieldAsc('longUrl')), ); self::assertCount(count($urls), $result); diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 77077142..77f6aa6a 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -135,11 +135,11 @@ class TagRepositoryTest extends DatabaseTestCase ['baz', 1, 3, 2], ]]; yield 'ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::TAG->value, 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromFieldAsc(OrderableField::TAG->value)), $defaultList, ]; - yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple( - [OrderableField::TAG->value, 'DESC'], + yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromFieldDesc( + OrderableField::TAG->value, )), [ ['foo', 2, 4, 3], ['baz', 1, 3, 2], @@ -147,9 +147,7 @@ class TagRepositoryTest extends DatabaseTestCase ['another', 0, 0, 0], ]]; yield 'short URLs count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple( - [OrderableField::SHORT_URLS_COUNT->value, 'ASC'], - )), + new TagsListFiltering(null, null, null, Ordering::fromFieldAsc(OrderableField::SHORT_URLS_COUNT->value)), [ ['another', 0, 0, 0], ['baz', 1, 3, 2], @@ -158,9 +156,7 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'short URLs count DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple( - [OrderableField::SHORT_URLS_COUNT->value, 'DESC'], - )), + new TagsListFiltering(null, null, null, Ordering::fromFieldDesc(OrderableField::SHORT_URLS_COUNT->value)), [ ['bar', 3, 3, 2], ['foo', 2, 4, 3], @@ -169,7 +165,7 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'visits count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromFieldAsc(OrderableField::VISITS->value)), [ ['another', 0, 0, 0], ['bar', 3, 3, 2], @@ -178,9 +174,7 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'non-bot visits count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple( - [OrderableField::NON_BOT_VISITS->value, 'ASC'], - )), + new TagsListFiltering(null, null, null, Ordering::fromFieldAsc(OrderableField::NON_BOT_VISITS->value)), [ ['another', 0, 0, 0], ['bar', 3, 3, 2], @@ -189,7 +183,7 @@ class TagRepositoryTest extends DatabaseTestCase ], ]; yield 'visits count DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'DESC'])), + new TagsListFiltering(null, null, null, Ordering::fromFieldDesc(OrderableField::VISITS->value)), [ ['foo', 2, 4, 3], ['bar', 3, 3, 2], @@ -204,8 +198,8 @@ class TagRepositoryTest extends DatabaseTestCase ['baz', 1, 3, 2], ['foo', 1, 3, 2], ]]; - yield 'combined' => [new TagsListFiltering(1, null, null, Ordering::fromTuple( - [OrderableField::SHORT_URLS_COUNT->value, 'DESC'], + yield 'combined' => [new TagsListFiltering(1, null, null, Ordering::fromFieldDesc( + OrderableField::SHORT_URLS_COUNT->value, ), ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ From a327e6c0a7dd1e6f3a3d32984df42d2dbb4c0cbf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Mar 2024 16:54:49 +0100 Subject: [PATCH 10/64] Make Visit::jsonSerialize() return different props for orphan visits --- .../Visit/AbstractVisitsListCommand.php | 5 +- module/Core/src/Visit/Entity/Visit.php | 104 +++++++++--------- .../OrphanVisitDataTransformer.php | 6 +- 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index a999760e..a3e8a43c 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -54,7 +54,10 @@ abstract class AbstractVisitsListCommand extends Command $extraKeys = array_keys($extraFields); $rowData = [ - ...$visit->jsonSerialize(), + 'referer' => $visit->referer, + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => $visit->userAgent, + 'potentialBot' => $visit->potentialBot, 'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown', 'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown', ...$extraFields, diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 0302f898..178fc283 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -25,17 +25,60 @@ class Visit extends AbstractEntity implements JsonSerializable public readonly VisitType $type, public readonly string $userAgent, public readonly string $referer, - private readonly bool $potentialBot, + public readonly bool $potentialBot, public readonly ?string $remoteAddr = null, public readonly ?string $visitedUrl = null, private ?VisitLocation $visitLocation = null, + // TODO Make public readonly once VisitRepositoryTest does not try to set it private Chronos $date = new Chronos(), ) { } public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self { - return self::hydrateFromVisitor($shortUrl, VisitType::VALID_SHORT_URL, $visitor, $anonymize); + return self::fromVisitor($shortUrl, VisitType::VALID_SHORT_URL, $visitor, $anonymize); + } + + public static function forBasePath(Visitor $visitor, bool $anonymize = true): self + { + return self::fromVisitor(null, VisitType::BASE_URL, $visitor, $anonymize); + } + + public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self + { + return self::fromVisitor(null, VisitType::INVALID_SHORT_URL, $visitor, $anonymize); + } + + public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self + { + return self::fromVisitor(null, VisitType::REGULAR_404, $visitor, $anonymize); + } + + private static function fromVisitor(?ShortUrl $shortUrl, VisitType $type, Visitor $visitor, bool $anonymize): self + { + return new self( + shortUrl: $shortUrl, + type: $type, + userAgent: $visitor->userAgent, + referer: $visitor->referer, + potentialBot: $visitor->isPotentialBot(), + remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), + visitedUrl: $visitor->visitedUrl, + ); + } + + private static function processAddress(?string $address, bool $anonymize): ?string + { + // Localhost address does not need to be anonymized + if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) { + return $address; + } + + try { + return IpAddress::fromString($address)->getAnonymizedCopy()->__toString(); + } catch (InvalidArgumentException) { + return null; + } } public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self @@ -69,52 +112,6 @@ class Visit extends AbstractEntity implements JsonSerializable ); } - public static function forBasePath(Visitor $visitor, bool $anonymize = true): self - { - return self::hydrateFromVisitor(null, VisitType::BASE_URL, $visitor, $anonymize); - } - - public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self - { - return self::hydrateFromVisitor(null, VisitType::INVALID_SHORT_URL, $visitor, $anonymize); - } - - public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self - { - return self::hydrateFromVisitor(null, VisitType::REGULAR_404, $visitor, $anonymize); - } - - private static function hydrateFromVisitor( - ?ShortUrl $shortUrl, - VisitType $type, - Visitor $visitor, - bool $anonymize, - ): self { - return new self( - shortUrl: $shortUrl, - type: $type, - userAgent: $visitor->userAgent, - referer: $visitor->referer, - potentialBot: $visitor->isPotentialBot(), - remoteAddr: self::processAddress($anonymize, $visitor->remoteAddress), - visitedUrl: $visitor->visitedUrl, - ); - } - - private static function processAddress(bool $anonymize, ?string $address): ?string - { - // Localhost addresses do not need to be anonymized - if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) { - return $address; - } - - try { - return IpAddress::fromString($address)->getAnonymizedCopy()->__toString(); - } catch (InvalidArgumentException) { - return null; - } - } - public function hasRemoteAddr(): bool { return ! empty($this->remoteAddr); @@ -160,12 +157,21 @@ class Visit extends AbstractEntity implements JsonSerializable public function jsonSerialize(): array { - return [ + $base = [ 'referer' => $this->referer, 'date' => $this->date->toAtomString(), 'userAgent' => $this->userAgent, 'visitLocation' => $this->visitLocation, 'potentialBot' => $this->potentialBot, ]; + if (! $this->isOrphan()) { + return $base; + } + + return [ + ...$base, + 'visitedUrl' => $this->visitedUrl, + 'type' => $this->type->value, + ]; } } diff --git a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php index c4dd6253..79e8f839 100644 --- a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php +++ b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php @@ -14,10 +14,6 @@ class OrphanVisitDataTransformer implements DataTransformerInterface */ public function transform($visit): array // phpcs:ignore { - $serializedVisit = $visit->jsonSerialize(); - $serializedVisit['visitedUrl'] = $visit->visitedUrl; - $serializedVisit['type'] = $visit->type->value; - - return $serializedVisit; + return $visit->jsonSerialize(); } } From d948543d5c6e3f0f4e1e3127617ae25e87f2b93e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Mar 2024 17:06:11 +0100 Subject: [PATCH 11/64] Wrap JSON serialization for any kind of visit in Visit entity itself --- module/Core/config/dependencies.config.php | 6 +- .../PublishingUpdatesGenerator.php | 10 +-- .../OrphanVisitDataTransformer.php | 19 ----- .../PublishingUpdatesGeneratorTest.php | 2 - module/Core/test/Visit/Entity/VisitTest.php | 61 +++++++++++++ .../OrphanVisitDataTransformerTest.php | 85 ------------------- module/Rest/config/dependencies.config.php | 5 +- .../src/Action/Visit/OrphanVisitsAction.php | 9 +- .../Action/Visit/OrphanVisitsActionTest.php | 9 +- 9 files changed, 71 insertions(+), 135 deletions(-) delete mode 100644 module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php delete mode 100644 module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index ed64a30e..d75c6bb8 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -68,7 +68,6 @@ return [ Visit\Geolocation\VisitLocator::class => ConfigAbstractFactory::class, Visit\Geolocation\VisitToLocationHelper::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, - Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class, Visit\Repository\VisitLocationRepository::class => [ EntityRepositoryFactory::class, Visit\Entity\Visit::class, @@ -199,10 +198,7 @@ return [ ], ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => [Options\UrlShortenerOptions::class], - EventDispatcher\PublishingUpdatesGenerator::class => [ - ShortUrl\Transformer\ShortUrlDataTransformer::class, - Visit\Transformer\OrphanVisitDataTransformer::class, - ], + EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], Importer\ImportedLinksProcessor::class => [ 'em', diff --git a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php index 06d06c84..82ada6e1 100644 --- a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -9,12 +9,10 @@ use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -final class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface +final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface { - public function __construct( - private readonly DataTransformerInterface $shortUrlTransformer, - private readonly DataTransformerInterface $orphanVisitTransformer, - ) { + public function __construct(private DataTransformerInterface $shortUrlTransformer) + { } public function newVisitUpdate(Visit $visit): Update @@ -28,7 +26,7 @@ final class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInte public function newOrphanVisitUpdate(Visit $visit): Update { return Update::forTopicAndPayload(Topic::NEW_ORPHAN_VISIT->value, [ - 'visit' => $this->orphanVisitTransformer->transform($visit), + 'visit' => $visit->jsonSerialize(), ]); } diff --git a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php deleted file mode 100644 index 79e8f839..00000000 --- a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php +++ /dev/null @@ -1,19 +0,0 @@ -jsonSerialize(); - } -} diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 94802cae..36fd6c8f 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -18,7 +18,6 @@ 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; class PublishingUpdatesGeneratorTest extends TestCase { @@ -28,7 +27,6 @@ class PublishingUpdatesGeneratorTest extends TestCase { $this->generator = new PublishingUpdatesGenerator( new ShortUrlDataTransformer(new ShortUrlStringifier([])), - new OrphanVisitDataTransformer(), ); } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 3fea2882..62c56b2e 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -4,13 +4,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit\Entity; +use Laminas\Diactoros\ServerRequestFactory; +use Laminas\Diactoros\Uri; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; +use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitTest extends TestCase { @@ -40,6 +45,62 @@ class VisitTest extends TestCase yield 'Guzzle' => ['guzzlehttp', true]; } + #[Test, DataProvider('provideOrphanVisits')] + public function isProperlyJsonSerializedWhenOrphan(Visit $visit, array $expectedResult): void + { + self::assertEquals($expectedResult, $visit->jsonSerialize()); + } + + public static function provideOrphanVisits(): iterable + { + yield 'base path visit' => [ + $visit = Visit::forBasePath(Visitor::emptyInstance()), + [ + 'referer' => '', + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => '', + 'visitLocation' => null, + 'potentialBot' => false, + 'visitedUrl' => '', + 'type' => VisitType::BASE_URL->value, + ], + ]; + yield 'invalid short url visit' => [ + $visit = Visit::forInvalidShortUrl(Visitor::fromRequest( + ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'foo') + ->withHeader('Referer', 'bar') + ->withUri(new Uri('https://example.com/foo')), + )), + [ + 'referer' => 'bar', + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => 'foo', + 'visitLocation' => null, + 'potentialBot' => false, + 'visitedUrl' => 'https://example.com/foo', + 'type' => VisitType::INVALID_SHORT_URL->value, + ], + ]; + yield 'regular 404 visit' => [ + $visit = Visit::forRegularNotFound( + Visitor::fromRequest( + ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'user-agent') + ->withHeader('Referer', 'referer') + ->withUri(new Uri('https://s.test/foo/bar')), + ), + )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), + [ + 'referer' => 'referer', + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => 'user-agent', + 'visitLocation' => $location, + 'potentialBot' => false, + 'visitedUrl' => 'https://s.test/foo/bar', + 'type' => VisitType::REGULAR_404->value, + ], + ]; + } + #[Test, DataProvider('provideAddresses')] public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void { diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php deleted file mode 100644 index 527f4fc9..00000000 --- a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php +++ /dev/null @@ -1,85 +0,0 @@ -transformer = new OrphanVisitDataTransformer(); - } - - #[Test, DataProvider('provideVisits')] - public function visitsAreParsedAsExpected(Visit $visit, array $expectedResult): void - { - $result = $this->transformer->transform($visit); - - self::assertEquals($expectedResult, $result); - } - - public static function provideVisits(): iterable - { - yield 'base path visit' => [ - $visit = Visit::forBasePath(Visitor::emptyInstance()), - [ - 'referer' => '', - 'date' => $visit->getDate()->toAtomString(), - 'userAgent' => '', - 'visitLocation' => null, - 'potentialBot' => false, - 'visitedUrl' => '', - 'type' => VisitType::BASE_URL->value, - ], - ]; - yield 'invalid short url visit' => [ - $visit = Visit::forInvalidShortUrl(Visitor::fromRequest( - ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'foo') - ->withHeader('Referer', 'bar') - ->withUri(new Uri('https://example.com/foo')), - )), - [ - 'referer' => 'bar', - 'date' => $visit->getDate()->toAtomString(), - 'userAgent' => 'foo', - 'visitLocation' => null, - 'potentialBot' => false, - 'visitedUrl' => 'https://example.com/foo', - 'type' => VisitType::INVALID_SHORT_URL->value, - ], - ]; - yield 'regular 404 visit' => [ - $visit = Visit::forRegularNotFound( - Visitor::fromRequest( - ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'user-agent') - ->withHeader('Referer', 'referer') - ->withUri(new Uri('https://s.test/foo/bar')), - ), - )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), - [ - 'referer' => 'referer', - 'date' => $visit->getDate()->toAtomString(), - 'userAgent' => 'user-agent', - 'visitLocation' => $location, - 'potentialBot' => false, - 'visitedUrl' => 'https://s.test/foo/bar', - 'type' => VisitType::REGULAR_404->value, - ], - ]; - } -} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 9396dd38..d334d5b0 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -89,10 +89,7 @@ return [ 'config.url_shortener.domain.hostname', ], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], - Action\Visit\OrphanVisitsAction::class => [ - Visit\VisitsStatsHelper::class, - Visit\Transformer\OrphanVisitDataTransformer::class, - ], + Action\Visit\OrphanVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\DeleteOrphanVisitsAction::class => [Visit\VisitsDeleter::class], Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\ShortUrl\ListShortUrlsAction::class => [ diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index 57244197..0224022d 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -8,7 +8,6 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; -use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -21,10 +20,8 @@ class OrphanVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/visits/orphan'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct( - private readonly VisitsStatsHelperInterface $visitsHelper, - private readonly DataTransformerInterface $orphanVisitTransformer, - ) { + public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) + { } public function handle(ServerRequestInterface $request): ResponseInterface @@ -34,7 +31,7 @@ class OrphanVisitsAction extends AbstractRestAction $visits = $this->visitsHelper->orphanVisits($params, $apiKey); return new JsonResponse([ - 'visits' => $this->serializePaginator($visits, $this->orphanVisitTransformer), + 'visits' => $this->serializePaginator($visits), ]); } } diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index efa14caa..d5bdfef9 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -11,7 +11,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; @@ -26,14 +25,11 @@ class OrphanVisitsActionTest extends TestCase { private OrphanVisitsAction $action; private MockObject & VisitsStatsHelperInterface $visitsHelper; - private MockObject & DataTransformerInterface $orphanVisitTransformer; protected function setUp(): void { $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); - $this->orphanVisitTransformer = $this->createMock(DataTransformerInterface::class); - - $this->action = new OrphanVisitsAction($this->visitsHelper, $this->orphanVisitTransformer); + $this->action = new OrphanVisitsAction($this->visitsHelper); } #[Test] @@ -45,9 +41,6 @@ class OrphanVisitsActionTest extends TestCase $this->isInstanceOf(OrphanVisitsParams::class), )->willReturn(new Paginator(new ArrayAdapter($visits))); $visitsAmount = count($visits); - $this->orphanVisitTransformer->expects($this->exactly($visitsAmount))->method('transform')->with( - $this->isInstanceOf(Visit::class), - )->willReturn([]); /** @var JsonResponse $response */ $response = $this->action->handle( From 6fe269193abe2e1cd365ca223acb0773ed923832 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Mar 2024 17:20:41 +0100 Subject: [PATCH 12/64] Expose visitedUrl when serializing any kind of visit, not only orphan visits --- docs/async-api/async-api.json | 13 +++++++------ docs/swagger/definitions/OrphanVisit.json | 6 +----- docs/swagger/definitions/Visit.json | 6 +++++- .../paths/v1_short-urls_{shortCode}_visits.json | 9 ++++++--- docs/swagger/paths/v2_domains_{domain}_visits.json | 9 ++++++--- docs/swagger/paths/v2_tags_{tag}_visits.json | 9 ++++++--- docs/swagger/paths/v2_visits_non-orphan.json | 9 ++++++--- module/Core/src/Visit/Entity/Visit.php | 2 +- .../PublishingUpdatesGeneratorTest.php | 1 + module/Core/test/Visit/Entity/VisitTest.php | 1 + 10 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 7cd838a8..83c424ea 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -232,6 +232,11 @@ "potentialBot": { "type": "boolean", "description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler" + }, + "visitedUrl": { + "type": "string", + "nullable": true, + "description": "The originally visited URL that triggered the tracking of this visit" } }, "example": { @@ -247,7 +252,8 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, - "potentialBot": false + "potentialBot": false, + "visitedUrl": "https://s.test" } }, "OrphanVisit": { @@ -256,11 +262,6 @@ { "type": "object", "properties": { - "visitedUrl": { - "type": "string", - "nullable": true, - "description": "The originally visited URL that triggered the tracking of this visit" - }, "type": { "type": "string", "enum": [ diff --git a/docs/swagger/definitions/OrphanVisit.json b/docs/swagger/definitions/OrphanVisit.json index a8b4954a..897c6049 100644 --- a/docs/swagger/definitions/OrphanVisit.json +++ b/docs/swagger/definitions/OrphanVisit.json @@ -1,14 +1,10 @@ { "type": "object", - "required": ["visitedUrl", "type"], + "required": ["type"], "allOf": [{ "$ref": "./Visit.json" }], "properties": { - "visitedUrl": { - "type": ["string", "null"], - "description": "The originally visited URL that triggered the tracking of this visit" - }, "type": { "type": "string", "enum": [ diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index ecb6b9f9..c4589bb1 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -1,6 +1,6 @@ { "type": "object", - "required": ["referer", "date", "userAgent", "visitLocation"], + "required": ["referer", "date", "userAgent", "visitLocation", "potentialBot", "visitedUrl"], "properties": { "referer": { "type": "string", @@ -21,6 +21,10 @@ "potentialBot": { "type": "boolean", "description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler" + }, + "visitedUrl": { + "type": ["string", "null"], + "description": "The originally visited URL that triggered the tracking of this visit" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index 71e70148..f3799f13 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -100,7 +100,8 @@ "date": "2015-08-20T05:05:03+04:00", "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 + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": "https://t.co", @@ -115,14 +116,16 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, - "potentialBot": false + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": null, "date": "2015-08-20T05:05:03+04:00", "userAgent": "some_web_crawler/1.4", "visitLocation": null, - "potentialBot": true + "potentialBot": true, + "visitedUrl": "https://s.test" } ], "pagination": { diff --git a/docs/swagger/paths/v2_domains_{domain}_visits.json b/docs/swagger/paths/v2_domains_{domain}_visits.json index d3acf60e..a477cb8e 100644 --- a/docs/swagger/paths/v2_domains_{domain}_visits.json +++ b/docs/swagger/paths/v2_domains_{domain}_visits.json @@ -103,7 +103,8 @@ "date": "2015-08-20T05:05:03+04:00", "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 + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": "https://t.co", @@ -118,14 +119,16 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, - "potentialBot": false + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": null, "date": "2015-08-20T05:05:03+04:00", "userAgent": "some_web_crawler/1.4", "visitLocation": null, - "potentialBot": true + "potentialBot": true, + "visitedUrl": "https://s.test" } ], "pagination": { diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json index 2a0148ec..1f3dabf2 100644 --- a/docs/swagger/paths/v2_tags_{tag}_visits.json +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -103,7 +103,8 @@ "date": "2015-08-20T05:05:03+04:00", "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 + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": "https://t.co", @@ -118,14 +119,16 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, - "potentialBot": false + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": null, "date": "2015-08-20T05:05:03+04:00", "userAgent": "some_web_crawler/1.4", "visitLocation": null, - "potentialBot": true + "potentialBot": true, + "visitedUrl": "https://s.test" } ], "pagination": { diff --git a/docs/swagger/paths/v2_visits_non-orphan.json b/docs/swagger/paths/v2_visits_non-orphan.json index da0bdd14..65b11252 100644 --- a/docs/swagger/paths/v2_visits_non-orphan.json +++ b/docs/swagger/paths/v2_visits_non-orphan.json @@ -94,7 +94,8 @@ "date": "2015-08-20T05:05:03+04:00", "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 + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": "https://t.co", @@ -109,14 +110,16 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, - "potentialBot": false + "potentialBot": false, + "visitedUrl": "https://s.test" }, { "referer": null, "date": "2015-08-20T05:05:03+04:00", "userAgent": "some_web_crawler/1.4", "visitLocation": null, - "potentialBot": true + "potentialBot": true, + "visitedUrl": "https://s.test" } ], "pagination": { diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 178fc283..86854945 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -163,6 +163,7 @@ class Visit extends AbstractEntity implements JsonSerializable 'userAgent' => $this->userAgent, 'visitLocation' => $this->visitLocation, 'potentialBot' => $this->potentialBot, + 'visitedUrl' => $this->visitedUrl, ]; if (! $this->isOrphan()) { return $base; @@ -170,7 +171,6 @@ class Visit extends AbstractEntity implements JsonSerializable return [ ...$base, - 'visitedUrl' => $this->visitedUrl, 'type' => $this->type->value, ]; } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 36fd6c8f..032c3263 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -68,6 +68,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'visitLocation' => null, 'date' => $visit->getDate()->toAtomString(), 'potentialBot' => false, + 'visitedUrl' => '', ], ], $update->payload); } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 62c56b2e..d9c50af6 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -30,6 +30,7 @@ class VisitTest extends TestCase 'userAgent' => $userAgent, 'visitLocation' => null, 'potentialBot' => $expectedToBePotentialBot, + 'visitedUrl' => $visit->visitedUrl, ], $visit->jsonSerialize()); } From b4c46ce2226dc31a7e7c49e994288bbc73b2c711 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Mar 2024 17:24:46 +0100 Subject: [PATCH 13/64] Update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c58d8e6..8157c47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit. + + Previously, this was exposed only for orphan visits, since this can be an arbitrary value for those. ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. From c599d8a0ede1b0ab619e16295f5c2363f0733193 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Mar 2024 13:04:42 +0100 Subject: [PATCH 14/64] Make sure tags fallback to empty array when null --- module/Core/src/ShortUrl/Model/ShortUrlCreation.php | 2 +- module/Core/src/ShortUrl/Model/ShortUrlEdition.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index b0c87f99..f5336075 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -68,7 +68,7 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface ShortUrlInputFilter::SHORT_CODE_LENGTH, ) ?? DEFAULT_SHORT_CODES_LENGTH, apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY), - tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), + tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS) ?? [], title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 36a99f5f..6296f84d 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -60,7 +60,7 @@ final readonly class ShortUrlEdition implements TitleResolutionModelInterface 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), + tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS) ?? [], titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data), title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data), From 17d37a062a010124ff39a84388a475cee4b64343 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Mar 2024 08:33:52 +0100 Subject: [PATCH 15/64] Add new table to track short URL visits counts --- ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 5 ++ ....Core.Visit.Entity.ShortUrlVisitsCount.php | 41 ++++++++++++ .../Core/migrations/Version20240306132518.php | 63 +++++++++++++++++++ .../Core/migrations/Version20240318084804.php | 59 +++++++++++++++++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 18 +++--- .../Repository/ShortUrlListRepository.php | 13 ++-- .../src/Visit/Entity/ShortUrlVisitsCount.php | 19 ++++++ .../PublishingUpdatesGeneratorTest.php | 14 ++++- 8 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php create mode 100644 module/Core/migrations/Version20240306132518.php create mode 100644 module/Core/migrations/Version20240318084804.php create mode 100644 module/Core/src/Visit/Entity/ShortUrlVisitsCount.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 358ee6bd..b159da13 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('visitsCounts', Visit\Entity\ShortUrlVisitsCount::class) + ->mappedBy('shortUrl') + ->fetchExtraLazy() // TODO Check if this makes sense + ->build(); + $builder->createManyToMany('tags', Tag\Entity\Tag::class) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->addInverseJoinColumn('tag_id', 'id', onDelete: 'CASCADE') diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php new file mode 100644 index 00000000..f65be80a --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php @@ -0,0 +1,41 @@ +setTable(determineTableName('short_url_visits_counts', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + $builder->createField('potentialBot', Types::BOOLEAN) + ->columnName('potential_bot') + ->option('default', false) + ->build(); + + $builder->createField('count', Types::BIGINT) + ->columnName('count') + ->option('unsigned', true) + ->build(); + + $builder->createField('slotId', Types::INTEGER) + ->columnName('slot_id') + ->option('unsigned', true) + ->build(); + + $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) + ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') + ->build(); +}; diff --git a/module/Core/migrations/Version20240306132518.php b/module/Core/migrations/Version20240306132518.php new file mode 100644 index 00000000..21847b81 --- /dev/null +++ b/module/Core/migrations/Version20240306132518.php @@ -0,0 +1,63 @@ +skipIf($schema->hasTable('short_url_visits_counts')); + + $table = $schema->createTable('short_url_visits_counts'); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->addColumn('potential_bot', Types::BOOLEAN, ['default' => false]); + + $table->addColumn('slot_id', Types::INTEGER, [ + 'unsigned' => true, + 'notnull' => true, + 'default' => 1, + ]); + + $table->addColumn('count', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + 'default' => 1, + ]); + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable('short_url_visits_counts')); + $schema->dropTable('short_url_visits_counts'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/migrations/Version20240318084804.php b/module/Core/migrations/Version20240318084804.php new file mode 100644 index 00000000..6b906107 --- /dev/null +++ b/module/Core/migrations/Version20240318084804.php @@ -0,0 +1,59 @@ +connection->createQueryBuilder(); + $result = $qb->select('id') + ->from('short_urls') + ->executeQuery(); + + while ($shortUrlId = $result->fetchOne()) { + $visitsQb = $this->connection->createQueryBuilder(); + $visitsQb->select('COUNT(id)') + ->from('visits') + ->where($visitsQb->expr()->eq('short_url_id', ':short_url_id')) + ->andWhere($visitsQb->expr()->eq('potential_bot', ':potential_bot')) + ->setParameter('short_url_id', $shortUrlId); + + $botsCount = $visitsQb->setParameter('potential_bot', '1')->executeQuery()->fetchOne(); + $nonBotsCount = $visitsQb->setParameter('potential_bot', '0')->executeQuery()->fetchOne(); + + $this->connection->createQueryBuilder() + ->insert('short_url_visits_counts') + ->values([ + 'short_url_id' => ':short_url_id', + 'count' => ':count', + 'potential_bot' => '1', + ]) + ->setParameters([ + 'short_url_id' => $shortUrlId, + 'count' => $botsCount, + ]) + ->executeStatement(); + $this->connection->createQueryBuilder() + ->insert('short_url_visits_counts') + ->values([ + 'short_url_id' => ':short_url_id', + 'count' => ':count', + 'potential_bot' => '0', + ]) + ->setParameters([ + 'short_url_id' => $shortUrlId, + 'count' => $nonBotsCount, + ]) + ->executeStatement(); + } + } +} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 474d5afc..cc7ebc85 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -19,6 +19,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Tag\Entity\Tag; +use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use Shlinkio\Shlink\Core\Visit\Model\VisitType; @@ -37,6 +38,7 @@ class ShortUrl extends AbstractEntity /** * @param Collection $tags * @param Collection & Selectable $visits + * @param Collection & Selectable $visitsCounts */ private function __construct( private string $longUrl, @@ -44,6 +46,7 @@ class ShortUrl extends AbstractEntity private Chronos $dateCreated = new Chronos(), private Collection $tags = new ArrayCollection(), private Collection & Selectable $visits = new ArrayCollection(), + private Collection & Selectable $visitsCounts = new ArrayCollection(), private ?Chronos $validSince = null, private ?Chronos $validUntil = null, private ?int $maxVisits = null, @@ -179,16 +182,16 @@ class ShortUrl extends AbstractEntity return $this->shortCode; } - public function getDateCreated(): Chronos - { - return $this->dateCreated; - } - public function getDomain(): ?Domain { return $this->domain; } + public function forwardQuery(): bool + { + return $this->forwardQuery; + } + public function reachedVisits(int $visitsAmount): bool { return count($this->visits) >= $visitsAmount; @@ -214,11 +217,6 @@ class ShortUrl extends AbstractEntity return $this; } - public function forwardQuery(): bool - { - return $this->forwardQuery; - } - /** * @throws ShortCodeCannotBeRegeneratedException */ diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index d6f7e421..2f638c73 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -60,12 +60,17 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $leftJoinConditions[] = $qb->expr()->eq('v.potentialBot', 'false'); } + $qb->addSelect('SUM(v.count)') + ->leftJoin('s.visitsCounts', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) + ->groupBy('s') + ->orderBy('SUM(v.count)', $order); + // FIXME This query is inefficient. // Diagnostic: It might need to use a sub-query, as done with the tags list query. - $qb->addSelect('COUNT(DISTINCT v)') - ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) - ->groupBy('s') - ->orderBy('COUNT(DISTINCT v)', $order); +// $qb->addSelect('COUNT(DISTINCT v)') +// ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) +// ->groupBy('s') +// ->orderBy('COUNT(DISTINCT v)', $order); } } diff --git a/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php b/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php new file mode 100644 index 00000000..ff3580b3 --- /dev/null +++ b/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php @@ -0,0 +1,19 @@ +now = Chronos::now(); + Chronos::setTestNow($this->now); + $this->generator = new PublishingUpdatesGenerator( new ShortUrlDataTransformer(new ShortUrlStringifier([])), ); } + protected function tearDown(): void + { + Chronos::setTestNow(); + } + #[Test, DataProvider('provideMethod')] public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void { @@ -49,7 +59,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'https://longUrl', - 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), + 'dateCreated' => $this->now->toAtomString(), 'tags' => [], 'meta' => [ 'validSince' => null, @@ -123,7 +133,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'https://longUrl', - 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), + 'dateCreated' => $this->now->toAtomString(), 'tags' => [], 'meta' => [ 'validSince' => null, From f678873e9fc8e5b7646a83ee6ccce276a8a5d358 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 21 Mar 2024 08:47:39 +0100 Subject: [PATCH 16/64] Use pre-calculated visits counts when listing short URLs --- .../Command/ShortUrl/ListShortUrlsCommand.php | 9 ++-- module/Core/src/ShortUrl/Entity/ShortUrl.php | 4 +- .../Model/ShortUrlWithVisitsSummary.php | 31 +++++++++++++ .../Adapter/ShortUrlRepositoryAdapter.php | 10 ++--- .../Repository/ShortUrlListRepository.php | 43 ++++++++----------- .../ShortUrlListRepositoryInterface.php | 4 +- .../Core/src/ShortUrl/ShortUrlListService.php | 10 ++--- .../Transformer/ShortUrlDataTransformer.php | 11 +++-- 8 files changed, 75 insertions(+), 47 deletions(-) create mode 100644 module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index c4346f14..b03e0312 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; @@ -23,10 +24,10 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function array_keys; -use function array_map; use function array_pad; use function explode; use function implode; +use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; class ListShortUrlsCommand extends Command @@ -184,10 +185,10 @@ class ListShortUrlsCommand extends Command ): Paginator { $shortUrls = $this->shortUrlService->listShortUrls($params); - $rows = array_map(function (ShortUrl $shortUrl) use ($columnsMap) { + $rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) { $rawShortUrl = $this->transformer->transform($shortUrl); - return array_map(fn (callable $call) => $call($rawShortUrl, $shortUrl), $columnsMap); - }, [...$shortUrls]); + return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl)); + }); ShlinkTable::default($output)->render( array_keys($columnsMap), diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index cc7ebc85..ac21b34b 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -256,7 +256,7 @@ class ShortUrl extends AbstractEntity return true; } - public function toArray(): array + public function toArray(?VisitsSummary $precalculatedSummary = null): array { return [ 'shortCode' => $this->shortCode, @@ -272,7 +272,7 @@ class ShortUrl extends AbstractEntity 'title' => $this->title, 'crawlable' => $this->crawlable, 'forwardQuery' => $this->forwardQuery, - 'visitsSummary' => VisitsSummary::fromTotalAndNonBots( + 'visitsSummary' => $precalculatedSummary ?? VisitsSummary::fromTotalAndNonBots( count($this->visits), count($this->visits->matching( Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)), diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php new file mode 100644 index 00000000..8244cde4 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -0,0 +1,31 @@ +shortUrl->toArray($this->visitsSummary); + } +} diff --git a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 83ce8bd9..56f8e5a5 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -11,13 +11,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlRepositoryAdapter implements AdapterInterface +readonly class ShortUrlRepositoryAdapter implements AdapterInterface { public function __construct( - private readonly ShortUrlListRepositoryInterface $repository, - private readonly ShortUrlsParams $params, - private readonly ?ApiKey $apiKey, - private readonly string $defaultDomain, + private ShortUrlListRepositoryInterface $repository, + private ShortUrlsParams $params, + private ?ApiKey $apiKey, + private string $defaultDomain, ) { } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 2f638c73..0bcf7974 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -11,34 +11,39 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use function array_column; +use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface { /** - * @return ShortUrl[] + * @return ShortUrlWithVisitsSummary[] */ public function findList(ShortUrlsListFiltering $filtering): array { $qb = $this->createListQueryBuilder($filtering); - $qb->select('DISTINCT s') + $qb->select('DISTINCT s AS shortUrl', 'SUM(v.count) AS visitsCount', 'SUM(v2.count) AS nonBotVisitsCount') + ->addSelect('SUM(v.count)') + ->leftJoin('s.visitsCounts', 'v') + ->leftJoin('s.visitsCounts', 'v2', Join::WITH, $qb->expr()->andX( + $qb->expr()->eq('v.shortUrl', 's'), + $qb->expr()->eq('v.potentialBot', 'false'), + )) + ->groupBy('s') ->setMaxResults($filtering->limit) ->setFirstResult($filtering->offset); $this->processOrderByForList($qb, $filtering); + /** @var array{shortUrl: ShortUrl, visitsCount: string, nonBotVisitsCount: string}[] $result */ $result = $qb->getQuery()->getResult(); - if (OrderableField::isVisitsField($filtering->orderBy->field ?? '')) { - return array_column($result, 0); - } - - return $result; + return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s)); } private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void @@ -51,26 +56,12 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh } $order = $filtering->orderBy->direction; - if (OrderableField::isBasicField($fieldName)) { $qb->orderBy('s.' . $fieldName, $order); - } elseif (OrderableField::isVisitsField($fieldName)) { - $leftJoinConditions = [$qb->expr()->eq('v.shortUrl', 's')]; - if ($fieldName === OrderableField::NON_BOT_VISITS->value) { - $leftJoinConditions[] = $qb->expr()->eq('v.potentialBot', 'false'); - } - - $qb->addSelect('SUM(v.count)') - ->leftJoin('s.visitsCounts', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) - ->groupBy('s') - ->orderBy('SUM(v.count)', $order); - - // FIXME This query is inefficient. - // Diagnostic: It might need to use a sub-query, as done with the tags list query. -// $qb->addSelect('COUNT(DISTINCT v)') -// ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) -// ->groupBy('s') -// ->orderBy('COUNT(DISTINCT v)', $order); + } elseif (OrderableField::VISITS->value === $fieldName) { + $qb->orderBy('SUM(v.count)', $order); + } elseif (OrderableField::NON_BOT_VISITS->value === $fieldName) { + $qb->orderBy('SUM(v2.count)', $order); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php index 130e0db7..db3f8017 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Repository; -use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; interface ShortUrlListRepositoryInterface { /** - * @return ShortUrl[] + * @return ShortUrlWithVisitsSummary[] */ public function findList(ShortUrlsListFiltering $filtering): array; diff --git a/module/Core/src/ShortUrl/ShortUrlListService.php b/module/Core/src/ShortUrl/ShortUrlListService.php index 60f56554..d86c4988 100644 --- a/module/Core/src/ShortUrl/ShortUrlListService.php +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -6,22 +6,22 @@ namespace Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; -use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlListService implements ShortUrlListServiceInterface +readonly class ShortUrlListService implements ShortUrlListServiceInterface { public function __construct( - private readonly ShortUrlListRepositoryInterface $repo, - private readonly UrlShortenerOptions $urlShortenerOptions, + private ShortUrlListRepositoryInterface $repo, + private UrlShortenerOptions $urlShortenerOptions, ) { } /** - * @return ShortUrl[]|Paginator + * @return ShortUrlWithVisitsSummary[]|Paginator */ public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator { diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 09b9436b..413f5a69 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,7 +7,11 @@ 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\ShortUrl\Model\ShortUrlWithVisitsSummary; +/** + * @fixme Do not implement DataTransformerInterface, but a separate interface + */ readonly class ShortUrlDataTransformer implements DataTransformerInterface { public function __construct(private ShortUrlStringifierInterface $stringifier) @@ -15,13 +19,14 @@ readonly class ShortUrlDataTransformer implements DataTransformerInterface } /** - * @param ShortUrl $shortUrl + * @param ShortUrlWithVisitsSummary|ShortUrl $data */ - public function transform($shortUrl): array // phpcs:ignore + public function transform($data): array // phpcs:ignore { + $shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data; return [ 'shortUrl' => $this->stringifier->stringify($shortUrl), - ...$shortUrl->toArray(), + ...$data->toArray(), ]; } } From 3c89d252d26618f7c78ca1bd78868757dcc85891 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 21 Mar 2024 08:56:13 +0100 Subject: [PATCH 17/64] Simplify logic to match order by for short URL lists --- .../Repository/ShortUrlListRepository.php | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 0bcf7974..32bcdc28 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -48,21 +48,16 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void { - // With no explicit order by, fallback to dateCreated-DESC $fieldName = $filtering->orderBy->field; - if ($fieldName === null) { - $qb->orderBy('s.dateCreated', 'DESC'); - return; - } - $order = $filtering->orderBy->direction; - if (OrderableField::isBasicField($fieldName)) { - $qb->orderBy('s.' . $fieldName, $order); - } elseif (OrderableField::VISITS->value === $fieldName) { - $qb->orderBy('SUM(v.count)', $order); - } elseif (OrderableField::NON_BOT_VISITS->value === $fieldName) { - $qb->orderBy('SUM(v2.count)', $order); - } + + match (true) { + // With no explicit order by, fallback to dateCreated-DESC + $fieldName === null => $qb->orderBy('s.dateCreated', 'DESC'), + $fieldName === OrderableField::VISITS->value => $qb->orderBy('SUM(v.count)', $order), + $fieldName === OrderableField::NON_BOT_VISITS->value => $qb->orderBy('SUM(v2.count)', $order), + default => $qb->orderBy('s.' . $fieldName, $order), + }; } public function countList(ShortUrlsCountFiltering $filtering): int From 7d415e40b2e9f3b0bce1cc393b453fc03ca88035 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Mar 2024 08:45:52 +0100 Subject: [PATCH 18/64] Add unique index in short_url_visits_counts --- module/Core/migrations/Version20240306132518.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/Core/migrations/Version20240306132518.php b/module/Core/migrations/Version20240306132518.php index 21847b81..a2960e9b 100644 --- a/module/Core/migrations/Version20240306132518.php +++ b/module/Core/migrations/Version20240306132518.php @@ -48,6 +48,8 @@ final class Version20240306132518 extends AbstractMigration 'notnull' => true, 'default' => 1, ]); + + $table->addUniqueIndex(['short_url_id', 'potential_bot', 'slot_id'], 'UQ_slot_per_short_url'); } public function down(Schema $schema): void From 7afd3fd6a2cb883e37b4a71d7b240b26bc63d099 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 25 Mar 2024 19:22:13 +0100 Subject: [PATCH 19/64] Load visits and nonBotVisits via sub-queries in ShortUrlListRepository --- ....Core.Visit.Entity.ShortUrlVisitsCount.php | 2 + .../Core/migrations/Version20240318084804.php | 47 +++++++++---------- .../Model/ShortUrlWithVisitsSummary.php | 6 +-- .../Repository/ShortUrlListRepository.php | 37 ++++++++++----- 4 files changed, 53 insertions(+), 39 deletions(-) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php index f65be80a..8e06f5c0 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php @@ -38,4 +38,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') ->build(); + + $builder->addUniqueConstraint(['short_url_id', 'potential_bot', 'slot_id'], 'UQ_slot_per_short_url'); }; diff --git a/module/Core/migrations/Version20240318084804.php b/module/Core/migrations/Version20240318084804.php index 6b906107..a9501a2a 100644 --- a/module/Core/migrations/Version20240318084804.php +++ b/module/Core/migrations/Version20240318084804.php @@ -30,30 +30,29 @@ final class Version20240318084804 extends AbstractMigration $botsCount = $visitsQb->setParameter('potential_bot', '1')->executeQuery()->fetchOne(); $nonBotsCount = $visitsQb->setParameter('potential_bot', '0')->executeQuery()->fetchOne(); - $this->connection->createQueryBuilder() - ->insert('short_url_visits_counts') - ->values([ - 'short_url_id' => ':short_url_id', - 'count' => ':count', - 'potential_bot' => '1', - ]) - ->setParameters([ - 'short_url_id' => $shortUrlId, - 'count' => $botsCount, - ]) - ->executeStatement(); - $this->connection->createQueryBuilder() - ->insert('short_url_visits_counts') - ->values([ - 'short_url_id' => ':short_url_id', - 'count' => ':count', - 'potential_bot' => '0', - ]) - ->setParameters([ - 'short_url_id' => $shortUrlId, - 'count' => $nonBotsCount, - ]) - ->executeStatement(); + if ($botsCount > 0) { + $this->insertCount($shortUrlId, $botsCount, potentialBot: true); + } + if ($nonBotsCount > 0) { + $this->insertCount($shortUrlId, $nonBotsCount, potentialBot: false); + } } } + + private function insertCount(string $shortUrlId, int $count, bool $potentialBot): void + { + $this->connection->createQueryBuilder() + ->insert('short_url_visits_counts') + ->values([ + 'short_url_id' => ':short_url_id', + 'count' => ':count', + 'potential_bot' => ':potential_bot', + ]) + ->setParameters([ + 'short_url_id' => $shortUrlId, + 'count' => $count, + 'potential_bot' => $potentialBot ? '1' : '0', + ]) + ->executeStatement(); + } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php index 8244cde4..79bdb526 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -14,13 +14,13 @@ final readonly class ShortUrlWithVisitsSummary } /** - * @param array{shortUrl: ShortUrl, visitsCount: string|int, nonBotVisitsCount: string|int} $data + * @param array{shortUrl: ShortUrl, visits: string|int, nonBotVisits: string|int} $data */ public static function fromArray(array $data): self { return new self($data['shortUrl'], VisitsSummary::fromTotalAndNonBots( - (int) $data['visitsCount'], - (int) $data['nonBotVisitsCount'], + (int) $data['visits'], + (int) $data['nonBotVisits'], )); } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 32bcdc28..0c0c3df3 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; +use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use function Shlinkio\Shlink\Core\ArrayUtils\map; @@ -27,21 +28,33 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh */ public function findList(ShortUrlsListFiltering $filtering): array { + $buildVisitsSubQuery = function (string $alias, bool $excludingBots): string { + $vqb = $this->getEntityManager()->createQueryBuilder(); + $vqb->select('SUM(' . $alias . '.count)') + ->from(ShortUrlVisitsCount::class, $alias) + ->where($vqb->expr()->eq($alias . '.shortUrl', 's')); + + if ($excludingBots) { + $vqb->andWhere($vqb->expr()->eq($alias . '.potentialBot', ':potentialBot')); + } + + return $vqb->getDQL(); + }; + $qb = $this->createListQueryBuilder($filtering); - $qb->select('DISTINCT s AS shortUrl', 'SUM(v.count) AS visitsCount', 'SUM(v2.count) AS nonBotVisitsCount') - ->addSelect('SUM(v.count)') - ->leftJoin('s.visitsCounts', 'v') - ->leftJoin('s.visitsCounts', 'v2', Join::WITH, $qb->expr()->andX( - $qb->expr()->eq('v.shortUrl', 's'), - $qb->expr()->eq('v.potentialBot', 'false'), - )) - ->groupBy('s') + $qb->select( + 'DISTINCT s AS shortUrl', + '(' . $buildVisitsSubQuery('v', excludingBots: false) . ') AS ' . OrderableField::VISITS->value, + '(' . $buildVisitsSubQuery('v2', excludingBots: true) . ') AS ' . OrderableField::NON_BOT_VISITS->value, + ) ->setMaxResults($filtering->limit) - ->setFirstResult($filtering->offset); + ->setFirstResult($filtering->offset) + // This param is used in one of the sub-queries, but needs to set in the parent query + ->setParameter('potentialBot', 0); $this->processOrderByForList($qb, $filtering); - /** @var array{shortUrl: ShortUrl, visitsCount: string, nonBotVisitsCount: string}[] $result */ + /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string}[] $result */ $result = $qb->getQuery()->getResult(); return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s)); } @@ -54,8 +67,8 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh match (true) { // With no explicit order by, fallback to dateCreated-DESC $fieldName === null => $qb->orderBy('s.dateCreated', 'DESC'), - $fieldName === OrderableField::VISITS->value => $qb->orderBy('SUM(v.count)', $order), - $fieldName === OrderableField::NON_BOT_VISITS->value => $qb->orderBy('SUM(v2.count)', $order), + $fieldName === OrderableField::VISITS->value, + $fieldName === OrderableField::NON_BOT_VISITS->value => $qb->orderBy($fieldName, $order), default => $qb->orderBy('s.' . $fieldName, $order), }; } From 6074f4475dd9fd7b30c608067897dc44bb757f18 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Mar 2024 08:56:06 +0100 Subject: [PATCH 20/64] Add preFlush listener to track visits counts --- config/autoload/entity-manager.global.php | 7 + module/Core/config/dependencies.config.php | 1 + .../ShortUrlVisitsCountPreFlushListener.php | 146 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 3eb43edf..eae7e8a9 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use Doctrine\ORM\Events; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountPreFlushListener; use function Shlinkio\Shlink\Core\ArrayUtils\contains; return (static function (): array { @@ -60,6 +62,11 @@ return (static function (): array { 'proxies_dir' => 'data/proxies', 'load_mappings_using_functional_style' => true, 'default_repository_classname' => EntitySpecificationRepository::class, + 'listeners' => [ + Events::preFlush => [ + ShortUrlVisitsCountPreFlushListener::class, + ], + ], ], 'connection' => $resolveConnection(), ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index d75c6bb8..7d3bf763 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -76,6 +76,7 @@ return [ EntityRepositoryFactory::class, Visit\Entity\Visit::class, ], + Visit\Listener\ShortUrlVisitsCountPreFlushListener::class => InvokableFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php new file mode 100644 index 00000000..6a006e54 --- /dev/null +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php @@ -0,0 +1,146 @@ +getObjectManager(); + $entitiesToBeCreated = $em->getUnitOfWork()->getScheduledEntityInsertions(); + + foreach ($entitiesToBeCreated as $entity) { + $this->trackVisitCount($em, $entity); + } + } + + /** + * @throws Exception + */ + private function trackVisitCount(EntityManagerInterface $em, object $entity): void + { + // This is not a non-orphan visit + if (!$entity instanceof Visit || $entity->shortUrl === null) { + return; + } + $visit = $entity; + + // The short URL is not persisted yet + $shortUrlId = $visit->shortUrl->getId(); + if ($shortUrlId === null || $shortUrlId === '') { + return; + } + + $isBot = $visit->potentialBot; + $conn = $em->getConnection(); + $platformClass = $conn->getDatabasePlatform(); + + match ($platformClass::class) { + PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $shortUrlId, $isBot), + SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $shortUrlId, $isBot), + default => $this->incrementForMySQL($conn, $shortUrlId, $isBot), + }; + } + + /** + * @throws Exception + */ + private function incrementForMySQL(Connection $conn, string $shortUrlId, bool $potentialBot): void + { + $this->incrementWithPreparedStatement($conn, $shortUrlId, $potentialBot, <<incrementWithPreparedStatement($conn, $shortUrlId, $potentialBot, <<prepare($query); + $statement->bindValue('short_url_id', $shortUrlId); + $statement->bindValue('potential_bot', $potentialBot ? 1 : 0); + $statement->executeStatement(); + } + + /** + * @throws Exception + */ + private function incrementForOthers(Connection $conn, string $shortUrlId, bool $potentialBot): void + { + $slotId = rand(1, 100); + + // For engines without a specific UPSERT syntax, do a regular locked select followed by an insert or update + $qb = $conn->createQueryBuilder(); + $qb->select('id') + ->from('short_url_visits_counts') + ->where($qb->expr()->and( + $qb->expr()->eq('short_url_id', ':short_url_id'), + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )) + ->setParameter('short_url_id', $shortUrlId) + ->setParameter('potential_bot', $potentialBot) + ->setParameter('slot_id', $slotId) + ->forUpdate() + ->setMaxResults(1); + + $resultSet = $qb->executeQuery()->fetchOne(); + $writeQb = ! $resultSet + ? $conn->createQueryBuilder() + ->insert('short_url_visits_counts') + ->values([ + 'short_url_id' => ':short_url_id', + 'potential_bot' => ':potential_bot', + 'slot_id' => ':slot_id', + ]) + : $conn->createQueryBuilder() + ->update('short_url_visits_counts') + ->set('count', 'count + 1') + ->where($qb->expr()->and( + $qb->expr()->eq('short_url_id', ':short_url_id'), + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )); + + $writeQb->setParameter('short_url_id', $shortUrlId) + ->setParameter('potential_bot', $potentialBot) + ->setParameter('slot_id', $slotId) + ->executeStatement(); + } +} From 054eb426136f02a8a26730161912953c81bc916d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Mar 2024 09:06:47 +0100 Subject: [PATCH 21/64] Remove no-longer used methods in OrderableField enum --- composer.json | 4 ++++ module/Core/src/ShortUrl/Model/OrderableField.php | 15 --------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index a35dd727..2179a749 100644 --- a/composer.json +++ b/composer.json @@ -129,6 +129,10 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", + "test:api:sqlite": "DB_DRIVER=sqlite composer test:api", + "test:api:mysql": "DB_DRIVER=mysql composer test:api", + "test:api:maria": "DB_DRIVER=maria composer test:api", + "test:api:mssql": "DB_DRIVER=mssql composer test:api", "test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov", "test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov", "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml", diff --git a/module/Core/src/ShortUrl/Model/OrderableField.php b/module/Core/src/ShortUrl/Model/OrderableField.php index 685f6f12..a4053742 100644 --- a/module/Core/src/ShortUrl/Model/OrderableField.php +++ b/module/Core/src/ShortUrl/Model/OrderableField.php @@ -2,8 +2,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; -use function Shlinkio\Shlink\Core\ArrayUtils\contains; - enum OrderableField: string { case LONG_URL = 'longUrl'; @@ -12,17 +10,4 @@ enum OrderableField: string case TITLE = 'title'; case VISITS = 'visits'; case NON_BOT_VISITS = 'nonBotVisits'; - - public static function isBasicField(string $value): bool - { - return contains( - $value, - [self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value], - ); - } - - public static function isVisitsField(string $value): bool - { - return $value === self::VISITS->value || $value === self::NON_BOT_VISITS->value; - } } From 6fbb5a380ddab02a1a0d639524710fadaab4f246 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Mar 2024 09:24:55 +0100 Subject: [PATCH 22/64] Add missing default value for short url visits count --- ....Core.Visit.Entity.ShortUrlVisitsCount.php | 1 + .../ShortUrlVisitsCountPreFlushListener.php | 45 ++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php index 8e06f5c0..d4a8546b 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php @@ -28,6 +28,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('count', Types::BIGINT) ->columnName('count') ->option('unsigned', true) + ->option('default', 1) ->build(); $builder->createField('slotId', Types::INTEGER) diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php index 6a006e54..74812e44 100644 --- a/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php @@ -108,17 +108,20 @@ final readonly class ShortUrlVisitsCountPreFlushListener // For engines without a specific UPSERT syntax, do a regular locked select followed by an insert or update $qb = $conn->createQueryBuilder(); $qb->select('id') - ->from('short_url_visits_counts') - ->where($qb->expr()->and( - $qb->expr()->eq('short_url_id', ':short_url_id'), - $qb->expr()->eq('potential_bot', ':potential_bot'), - $qb->expr()->eq('slot_id', ':slot_id'), - )) - ->setParameter('short_url_id', $shortUrlId) - ->setParameter('potential_bot', $potentialBot) - ->setParameter('slot_id', $slotId) - ->forUpdate() - ->setMaxResults(1); + ->from('short_url_visits_counts') + ->where($qb->expr()->and( + $qb->expr()->eq('short_url_id', ':short_url_id'), + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )) + ->setParameter('short_url_id', $shortUrlId) + ->setParameter('potential_bot', $potentialBot) + ->setParameter('slot_id', $slotId) + ->setMaxResults(1); + + if ($conn->getDatabasePlatform()::class === SQLServerPlatform::class) { + $qb->forUpdate(); + } $resultSet = $qb->executeQuery()->fetchOne(); $writeQb = ! $resultSet @@ -130,17 +133,17 @@ final readonly class ShortUrlVisitsCountPreFlushListener 'slot_id' => ':slot_id', ]) : $conn->createQueryBuilder() - ->update('short_url_visits_counts') - ->set('count', 'count + 1') - ->where($qb->expr()->and( - $qb->expr()->eq('short_url_id', ':short_url_id'), - $qb->expr()->eq('potential_bot', ':potential_bot'), - $qb->expr()->eq('slot_id', ':slot_id'), - )); + ->update('short_url_visits_counts') + ->set('count', 'count + 1') + ->where($qb->expr()->and( + $qb->expr()->eq('short_url_id', ':short_url_id'), + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )); $writeQb->setParameter('short_url_id', $shortUrlId) - ->setParameter('potential_bot', $potentialBot) - ->setParameter('slot_id', $slotId) - ->executeStatement(); + ->setParameter('potential_bot', $potentialBot) + ->setParameter('slot_id', $slotId) + ->executeStatement(); } } From b236354fc78b3f66dc653b7703823a112f5cd3cc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Mar 2024 09:42:25 +0100 Subject: [PATCH 23/64] Fix order in which entities are flushed in ShortUrlListRepositoryTest --- config/autoload/entity-manager.global.php | 2 +- module/Core/src/Model/Ordering.php | 2 +- .../Persistence/ShortUrlsListFiltering.php | 6 +- .../Repository/ShortUrlListRepositoryTest.php | 194 ++++++++---------- 4 files changed, 86 insertions(+), 118 deletions(-) diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index eae7e8a9..fef50b56 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -5,8 +5,8 @@ declare(strict_types=1); use Doctrine\ORM\Events; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Config\EnvVars; - use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountPreFlushListener; + use function Shlinkio\Shlink\Core\ArrayUtils\contains; return (static function (): array { diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index 69a1429a..b56c0ea8 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -10,7 +10,7 @@ final readonly class Ordering private const ASC_DIR = 'ASC'; private const DEFAULT_DIR = self::ASC_DIR; - private function __construct(public ?string $field, public string $direction) + public function __construct(public ?string $field = null, public string $direction = self::DEFAULT_DIR) { } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index db8b9a70..589947dd 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -13,9 +13,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering { public function __construct( - public readonly ?int $limit, - public readonly ?int $offset, - public readonly Ordering $orderBy, + public readonly ?int $limit = null, + public readonly ?int $offset = null, + public readonly Ordering $orderBy = new Ordering(), ?string $searchTerm = null, array $tags = [], ?TagsMode $tagsMode = null, diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 315491d8..91e15737 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -25,6 +26,7 @@ use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function array_map; use function count; use function range; +use function Shlinkio\Shlink\Core\ArrayUtils\map; class ShortUrlListRepositoryTest extends DatabaseTestCase { @@ -60,6 +62,18 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($foo); $bar = ShortUrl::withLongUrl('https://bar'); + $this->getEntityManager()->persist($bar); + + $foo2 = ShortUrl::withLongUrl('https://foo_2'); + $ref = new ReflectionObject($foo2); + $dateProp = $ref->getProperty('dateCreated'); + $dateProp->setAccessible(true); + $dateProp->setValue($foo2, Chronos::now()->subDays(5)); + $this->getEntityManager()->persist($foo2); + + // Flush short URLs first + $this->getEntityManager()->flush(); + $visits = array_map(function () use ($bar) { $visit = Visit::forValidShortUrl($bar, Visitor::botInstance()); $this->getEntityManager()->persist($visit); @@ -67,9 +81,6 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase return $visit; }, range(0, 5)); $bar->setVisits(new ArrayCollection($visits)); - $this->getEntityManager()->persist($bar); - - $foo2 = ShortUrl::withLongUrl('https://foo_2'); $visits2 = array_map(function () use ($foo2) { $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); $this->getEntityManager()->persist($visit); @@ -77,68 +88,59 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase return $visit; }, range(0, 3)); $foo2->setVisits(new ArrayCollection($visits2)); - $ref = new ReflectionObject($foo2); - $dateProp = $ref->getProperty('dateCreated'); - $dateProp->setAccessible(true); - $dateProp->setValue($foo2, Chronos::now()->subDays(5)); - $this->getEntityManager()->persist($foo2); + // Flush visits afterwards $this->getEntityManager()->flush(); - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::none(), 'foo', ['bar']), - ); + $result = $this->repo->findList(new ShortUrlsListFiltering(searchTerm: 'foo', tags: ['bar'])); self::assertCount(1, $result); self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); - self::assertSame($foo, $result[0]); + self::assertSame($foo, $result[0]->shortUrl); // Assert searched text also applies to tags - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::none(), 'bar')); + $result = $this->repo->findList(new ShortUrlsListFiltering(searchTerm: 'bar')); self::assertCount(2, $result); self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); - self::assertContains($foo, $result); + self::assertContains($foo, map($result, fn (ShortUrlWithVisitsSummary $s) => $s->shortUrl)); - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::none())); + $result = $this->repo->findList(new ShortUrlsListFiltering()); self::assertCount(3, $result); - $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::none())); + $result = $this->repo->findList(new ShortUrlsListFiltering(limit: 2)); self::assertCount(2, $result); - $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::none())); + $result = $this->repo->findList(new ShortUrlsListFiltering(limit: 2, offset: 1)); self::assertCount(2, $result); - self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::none()))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(limit: 2, offset: 2))); - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromFieldDesc(OrderableField::VISITS->value)), - ); - self::assertCount(3, $result); - self::assertSame($bar, $result[0]); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromFieldDesc(OrderableField::NON_BOT_VISITS->value)), - ); - self::assertCount(3, $result); - self::assertSame($foo2, $result[0]); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::none(), null, [], null, DateRange::until( - Chronos::now()->subDays(2), - )), - ); - self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( - Chronos::now()->subDays(2), - )))); - self::assertSame($foo2, $result[0]); - - self::assertCount(2, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::none(), null, [], null, DateRange::since( - Chronos::now()->subDays(2), - )), + $result = $this->repo->findList(new ShortUrlsListFiltering( + orderBy: Ordering::fromFieldDesc(OrderableField::VISITS->value), )); + self::assertCount(3, $result); + self::assertSame($bar, $result[0]->shortUrl); + + // FIXME Check why this assertion fails +// $result = $this->repo->findList(new ShortUrlsListFiltering( +// orderBy: Ordering::fromFieldDesc(OrderableField::NON_BOT_VISITS->value), +// )); +// self::assertCount(3, $result); +// self::assertSame($foo2, $result[0]->shortUrl); + + $result = $this->repo->findList(new ShortUrlsListFiltering( + dateRange: DateRange::until(Chronos::now()->subDays(2)), + )); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering( + dateRange: DateRange::until(Chronos::now()->subDays(2)), + ))); + self::assertSame($foo2, $result[0]->shortUrl); + + self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( + dateRange: DateRange::since(Chronos::now()->subDays(2)), + ))); self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), + new ShortUrlsCountFiltering(dateRange: DateRange::since(Chronos::now()->subDays(2))), )); } @@ -152,15 +154,13 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromFieldAsc('longUrl')), - ); + $result = $this->repo->findList(new ShortUrlsListFiltering(orderBy: Ordering::fromFieldAsc('longUrl'))); self::assertCount(count($urls), $result); - self::assertEquals('https://a', $result[0]->getLongUrl()); - self::assertEquals('https://b', $result[1]->getLongUrl()); - self::assertEquals('https://c', $result[2]->getLongUrl()); - self::assertEquals('https://z', $result[3]->getLongUrl()); + self::assertEquals('https://a', $result[0]->shortUrl->getLongUrl()); + self::assertEquals('https://b', $result[1]->shortUrl->getLongUrl()); + self::assertEquals('https://c', $result[2]->shortUrl->getLongUrl()); + self::assertEquals('https://z', $result[3]->shortUrl->getLongUrl()); } #[Test] @@ -194,81 +194,55 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::none(), null, ['foo', 'bar']), - )); + self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering(tags: ['foo', 'bar']))); self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::none(), - null, - ['foo', 'bar'], - TagsMode::ANY, + tags: ['foo', 'bar'], + tagsMode: TagsMode::ANY, ))); self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::none(), - null, - ['foo', 'bar'], - TagsMode::ALL, + tags: ['foo', 'bar'], + tagsMode: TagsMode::ALL, ))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); - - self::assertCount(4, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::none(), null, ['bar', 'baz']), + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(tags: ['foo', 'bar']))); + self::assertEquals(5, $this->repo->countList( + new ShortUrlsCountFiltering(tags: ['foo', 'bar'], tagsMode: TagsMode::ANY), )); + self::assertEquals(1, $this->repo->countList( + new ShortUrlsCountFiltering(tags: ['foo', 'bar'], tagsMode: TagsMode::ALL), + )); + + self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering(tags: ['bar', 'baz']))); self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::none(), - null, - ['bar', 'baz'], - TagsMode::ANY, + tags: ['bar', 'baz'], + tagsMode: TagsMode::ANY, ))); self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::none(), - null, - ['bar', 'baz'], - TagsMode::ALL, + tags: ['bar', 'baz'], + tagsMode: TagsMode::ALL, ))); - self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); + self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(tags: ['bar', 'baz']))); self::assertEquals(4, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), + new ShortUrlsCountFiltering(tags: ['bar', 'baz'], tagsMode: TagsMode::ANY), )); self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), + new ShortUrlsCountFiltering(tags: ['bar', 'baz'], tagsMode: TagsMode::ALL), )); - self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::none(), null, ['foo', 'bar', 'baz']), - )); + self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering(tags: ['foo', 'bar', 'baz']))); self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::none(), - null, - ['foo', 'bar', 'baz'], - TagsMode::ANY, + tags: ['foo', 'bar', 'baz'], + tagsMode: TagsMode::ANY, ))); self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::none(), - null, - ['foo', 'bar', 'baz'], - TagsMode::ALL, + tags: ['foo', 'bar', 'baz'], + tagsMode: TagsMode::ALL, ))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(tags: ['foo', 'bar', 'baz']))); self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), + new ShortUrlsCountFiltering(tags: ['foo', 'bar', 'baz'], tagsMode: TagsMode::ANY), )); self::assertEquals(0, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), + new ShortUrlsCountFiltering(tags: ['foo', 'bar', 'baz'], tagsMode: TagsMode::ALL), )); } @@ -294,9 +268,6 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( - null, - null, - Ordering::none(), searchTerm: $searchTerm, defaultDomain: 'deFaulT-domain.com', ); @@ -339,9 +310,6 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $filtering = static fn (bool $excludeMaxVisitsReached, bool $excludePastValidUntil) => new ShortUrlsListFiltering( - null, - null, - Ordering::none(), excludeMaxVisitsReached: $excludeMaxVisitsReached, excludePastValidUntil: $excludePastValidUntil, ); From 3d7b1ca79960f018694b16f42277eb71c4d3b1c3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Mar 2024 23:26:44 +0100 Subject: [PATCH 24/64] Move from preFlush to onFlush + postFlush --- config/autoload/entity-manager.global.php | 7 +++---- module/Core/config/dependencies.config.php | 2 +- ...ner.php => ShortUrlVisitsCountTracker.php} | 21 +++++++++++++++---- .../Repository/ShortUrlListRepositoryTest.php | 21 +++++++------------ 4 files changed, 29 insertions(+), 22 deletions(-) rename module/Core/src/Visit/Listener/{ShortUrlVisitsCountPreFlushListener.php => ShortUrlVisitsCountTracker.php} (88%) diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index fef50b56..84233915 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -5,7 +5,7 @@ declare(strict_types=1); use Doctrine\ORM\Events; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Config\EnvVars; -use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountPreFlushListener; +use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountTracker; use function Shlinkio\Shlink\Core\ArrayUtils\contains; @@ -63,9 +63,8 @@ return (static function (): array { 'load_mappings_using_functional_style' => true, 'default_repository_classname' => EntitySpecificationRepository::class, 'listeners' => [ - Events::preFlush => [ - ShortUrlVisitsCountPreFlushListener::class, - ], + Events::onFlush => [ShortUrlVisitsCountTracker::class], + Events::postFlush => [ShortUrlVisitsCountTracker::class], ], ], 'connection' => $resolveConnection(), diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 7d3bf763..951c7b52 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -76,7 +76,7 @@ return [ EntityRepositoryFactory::class, Visit\Entity\Visit::class, ], - Visit\Listener\ShortUrlVisitsCountPreFlushListener::class => InvokableFactory::class, + Visit\Listener\ShortUrlVisitsCountTracker::class => InvokableFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php similarity index 88% rename from module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php rename to module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php index 74812e44..8d79c330 100644 --- a/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php @@ -10,20 +10,33 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Platforms\SQLServerPlatform; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Event\PreFlushEventArgs; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Event\PostFlushEventArgs; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use function rand; -final readonly class ShortUrlVisitsCountPreFlushListener +final class ShortUrlVisitsCountTracker { + /** @var object[] */ + private array $entitiesToBeCreated = []; + + public function onFlush(OnFlushEventArgs $args): void + { + // Track entities that are going to be created during this flush operation + $this->entitiesToBeCreated = $args->getObjectManager()->getUnitOfWork()->getScheduledEntityInsertions(); + } + /** * @throws Exception */ - public function preFlush(PreFlushEventArgs $args): void + public function postFlush(PostFlushEventArgs $args): void { $em = $args->getObjectManager(); - $entitiesToBeCreated = $em->getUnitOfWork()->getScheduledEntityInsertions(); + $entitiesToBeCreated = $this->entitiesToBeCreated; + + // Reset tracked entities until next flush operation + $this->entitiesToBeCreated = []; foreach ($entitiesToBeCreated as $entity) { $this->trackVisitCount($em, $entity); diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 91e15737..cad0569d 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -62,18 +62,6 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($foo); $bar = ShortUrl::withLongUrl('https://bar'); - $this->getEntityManager()->persist($bar); - - $foo2 = ShortUrl::withLongUrl('https://foo_2'); - $ref = new ReflectionObject($foo2); - $dateProp = $ref->getProperty('dateCreated'); - $dateProp->setAccessible(true); - $dateProp->setValue($foo2, Chronos::now()->subDays(5)); - $this->getEntityManager()->persist($foo2); - - // Flush short URLs first - $this->getEntityManager()->flush(); - $visits = array_map(function () use ($bar) { $visit = Visit::forValidShortUrl($bar, Visitor::botInstance()); $this->getEntityManager()->persist($visit); @@ -81,6 +69,9 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase return $visit; }, range(0, 5)); $bar->setVisits(new ArrayCollection($visits)); + $this->getEntityManager()->persist($bar); + + $foo2 = ShortUrl::withLongUrl('https://foo_2'); $visits2 = array_map(function () use ($foo2) { $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); $this->getEntityManager()->persist($visit); @@ -88,8 +79,12 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase return $visit; }, range(0, 3)); $foo2->setVisits(new ArrayCollection($visits2)); + $ref = new ReflectionObject($foo2); + $dateProp = $ref->getProperty('dateCreated'); + $dateProp->setAccessible(true); + $dateProp->setValue($foo2, Chronos::now()->subDays(5)); + $this->getEntityManager()->persist($foo2); - // Flush visits afterwards $this->getEntityManager()->flush(); $result = $this->repo->findList(new ShortUrlsListFiltering(searchTerm: 'foo', tags: ['bar'])); From 10e941cea6043f3e9d15be3775d2b007b9b9c388 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Mar 2024 09:15:21 +0100 Subject: [PATCH 25/64] Add missing COALESCE when summing visits counts --- module/Core/src/Model/Ordering.php | 10 +++++----- .../ShortUrl/Repository/ShortUrlListRepository.php | 4 ++-- .../src/Visit/Listener/ShortUrlVisitsCountTracker.php | 4 ++-- .../Repository/ShortUrlListRepositoryTest.php | 11 +++++------ 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index b56c0ea8..e1b91510 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -14,6 +14,11 @@ final readonly class Ordering { } + public static function none(): self + { + return new self(); + } + /** * @param array{string|null, string|null} $props */ @@ -23,11 +28,6 @@ final readonly class Ordering return new self($field, $dir ?? self::DEFAULT_DIR); } - public static function none(): self - { - return new self(null, self::DEFAULT_DIR); - } - public static function fromFieldAsc(string $field): self { return new self($field, self::ASC_DIR); diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 0c0c3df3..790a3dbb 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -30,7 +30,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh { $buildVisitsSubQuery = function (string $alias, bool $excludingBots): string { $vqb = $this->getEntityManager()->createQueryBuilder(); - $vqb->select('SUM(' . $alias . '.count)') + $vqb->select('COALESCE(SUM(' . $alias . '.count), 0)') ->from(ShortUrlVisitsCount::class, $alias) ->where($vqb->expr()->eq($alias . '.shortUrl', 's')); @@ -50,7 +50,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh ->setMaxResults($filtering->limit) ->setFirstResult($filtering->offset) // This param is used in one of the sub-queries, but needs to set in the parent query - ->setParameter('potentialBot', 0); + ->setParameter('potentialBot', false); $this->processOrderByForList($qb, $filtering); diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php index 8d79c330..f64974dd 100644 --- a/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php @@ -128,7 +128,7 @@ final class ShortUrlVisitsCountTracker $qb->expr()->eq('slot_id', ':slot_id'), )) ->setParameter('short_url_id', $shortUrlId) - ->setParameter('potential_bot', $potentialBot) + ->setParameter('potential_bot', $potentialBot ? '1' : '0') ->setParameter('slot_id', $slotId) ->setMaxResults(1); @@ -155,7 +155,7 @@ final class ShortUrlVisitsCountTracker )); $writeQb->setParameter('short_url_id', $shortUrlId) - ->setParameter('potential_bot', $potentialBot) + ->setParameter('potential_bot', $potentialBot ? '1' : '0') ->setParameter('slot_id', $slotId) ->executeStatement(); } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index cad0569d..95924956 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -115,12 +115,11 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase self::assertCount(3, $result); self::assertSame($bar, $result[0]->shortUrl); - // FIXME Check why this assertion fails -// $result = $this->repo->findList(new ShortUrlsListFiltering( -// orderBy: Ordering::fromFieldDesc(OrderableField::NON_BOT_VISITS->value), -// )); -// self::assertCount(3, $result); -// self::assertSame($foo2, $result[0]->shortUrl); + $result = $this->repo->findList(new ShortUrlsListFiltering( + orderBy: Ordering::fromFieldDesc(OrderableField::NON_BOT_VISITS->value), + )); + self::assertCount(3, $result); + self::assertSame($foo2, $result[0]->shortUrl); $result = $this->repo->findList(new ShortUrlsListFiltering( dateRange: DateRange::until(Chronos::now()->subDays(2)), From 8417498f08eea372b1e68f52444223b22127b5d0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Mar 2024 09:33:19 +0100 Subject: [PATCH 26/64] Fixes on static check and unit tests --- .../Command/ShortUrl/ListShortUrlsCommand.php | 10 ++++++++-- .../ShortUrl/ListShortUrlsCommandTest.php | 17 ++++++++++------- .../Model/ShortUrlWithVisitsSummary.php | 7 ++++++- .../ShortUrl/ShortUrlListServiceInterface.php | 4 ++-- .../Listener/ShortUrlVisitsCountTracker.php | 8 ++++---- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index b03e0312..4e3a7706 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -177,6 +177,9 @@ class ListShortUrlsCommand extends Command return ExitCode::EXIT_SUCCESS; } + /** + * @param array $columnsMap + */ private function renderPage( OutputInterface $output, array $columnsMap, @@ -186,8 +189,8 @@ class ListShortUrlsCommand extends Command $shortUrls = $this->shortUrlService->listShortUrls($params); $rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) { - $rawShortUrl = $this->transformer->transform($shortUrl); - return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl)); + $serializedShortUrl = $this->transformer->transform($shortUrl); + return map($columnsMap, fn (callable $call) => $call($serializedShortUrl, $shortUrl->shortUrl)); }); ShlinkTable::default($output)->render( @@ -210,6 +213,9 @@ class ListShortUrlsCommand extends Command return $dir === null ? $field : sprintf('%s-%s', $field, $dir); } + /** + * @return array + */ private function resolveColumnsMap(InputInterface $input): array { $pickProp = static fn (string $prop): callable => static fn (array $shortUrl) => $shortUrl[$prop]; diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 6859ec13..6016da1c 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; @@ -47,7 +48,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 50; $i++) { - $data[] = ShortUrl::withLongUrl('https://url_' . $i); + $data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); } $this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters() @@ -69,7 +70,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 30; $i++) { - $data[] = ShortUrl::withLongUrl('https://url_' . $i); + $data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); } $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( @@ -111,11 +112,13 @@ class ListShortUrlsCommandTest extends TestCase $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( ShortUrlsParams::emptyInstance(), )->willReturn(new Paginator(new ArrayAdapter([ - ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'https://foo.com', - 'tags' => ['foo', 'bar', 'baz'], - 'apiKey' => $apiKey, - ])), + ShortUrlWithVisitsSummary::fromShortUrl( + ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo.com', + 'tags' => ['foo', 'bar', 'baz'], + 'apiKey' => $apiKey, + ])), + ), ]))); $this->commandTester->setInputs(['y']); diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php index 79bdb526..50efaaee 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; final readonly class ShortUrlWithVisitsSummary { - private function __construct(public ShortUrl $shortUrl, public VisitsSummary $visitsSummary) + private function __construct(public ShortUrl $shortUrl, private ?VisitsSummary $visitsSummary = null) { } @@ -24,6 +24,11 @@ final readonly class ShortUrlWithVisitsSummary )); } + public static function fromShortUrl(ShortUrl $shortUrl): self + { + return new self($shortUrl); + } + public function toArray(): array { return $this->shortUrl->toArray($this->visitsSummary); diff --git a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php index ef7b31c2..ffbb1374 100644 --- a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlListServiceInterface { /** - * @return ShortUrl[]|Paginator + * @return ShortUrlWithVisitsSummary[]|Paginator */ public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; } diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php index f64974dd..25df0b83 100644 --- a/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php @@ -48,14 +48,14 @@ final class ShortUrlVisitsCountTracker */ private function trackVisitCount(EntityManagerInterface $em, object $entity): void { - // This is not a non-orphan visit - if (!$entity instanceof Visit || $entity->shortUrl === null) { + // This is not a visit + if (!$entity instanceof Visit) { return; } $visit = $entity; - // The short URL is not persisted yet - $shortUrlId = $visit->shortUrl->getId(); + // The short URL is not persisted yet or this is an orphan visit + $shortUrlId = $visit->shortUrl?->getId(); if ($shortUrlId === null || $shortUrlId === '') { return; } From cef30c8e2d943fa61eaf9dbd7e22f26d994add39 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Mar 2024 19:08:00 +0100 Subject: [PATCH 27/64] Fix type in Version20240318084804 --- module/Core/migrations/Version20240318084804.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/migrations/Version20240318084804.php b/module/Core/migrations/Version20240318084804.php index a9501a2a..228fa474 100644 --- a/module/Core/migrations/Version20240318084804.php +++ b/module/Core/migrations/Version20240318084804.php @@ -39,7 +39,7 @@ final class Version20240318084804 extends AbstractMigration } } - private function insertCount(string $shortUrlId, int $count, bool $potentialBot): void + private function insertCount(string|int $shortUrlId, int $count, bool $potentialBot): void { $this->connection->createQueryBuilder() ->insert('short_url_visits_counts') From 4a05c4be400e89edfc85e8ac38f127721be26790 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Mar 2024 19:14:41 +0100 Subject: [PATCH 28/64] Wrap visits tracking in transaction --- module/Core/src/Visit/VisitsTracker.php | 18 ++++++++++-------- module/Core/test/Visit/VisitsTrackerTest.php | 2 ++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index 9e4b88df..dd520e8f 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -12,12 +12,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -class VisitsTracker implements VisitsTrackerInterface +readonly class VisitsTracker implements VisitsTrackerInterface { public function __construct( - private readonly ORM\EntityManagerInterface $em, - private readonly EventDispatcherInterface $eventDispatcher, - private readonly TrackingOptions $options, + private ORM\EntityManagerInterface $em, + private EventDispatcherInterface $eventDispatcher, + private TrackingOptions $options, ) { } @@ -71,10 +71,12 @@ class VisitsTracker implements VisitsTrackerInterface return; } - $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); - $this->em->persist($visit); - $this->em->flush(); + $this->em->wrapInTransaction(function () use ($createVisit, $visitor): void { + $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); + $this->em->persist($visit); + $this->em->flush(); - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); + }); } } diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 32cd10a8..d6cbf4bf 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -25,6 +25,8 @@ class VisitsTrackerTest extends TestCase protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); + $this->em->method('wrapInTransaction')->willReturnCallback(fn (callable $callback) => $callback()); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); } From da922fb2a7a1fbce5641d5d10ee90ee6ed7b7e86 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Mar 2024 09:43:54 +0100 Subject: [PATCH 29/64] Add ShortUrlVisitsCountTrackerTest --- .../src/Visit/Entity/ShortUrlVisitsCount.php | 4 +- .../Listener/ShortUrlVisitsCountTracker.php | 24 +++--- .../ShortUrlVisitsCountTrackerTest.php | 76 +++++++++++++++++++ phpunit-db.xml | 1 + phpunit.xml.dist | 1 + 5 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php diff --git a/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php b/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php index ff3580b3..d513cfe8 100644 --- a/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php +++ b/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php @@ -12,8 +12,8 @@ class ShortUrlVisitsCount extends AbstractEntity public function __construct( private readonly ShortUrl $shortUrl, private readonly bool $potentialBot = false, - private readonly int $slotId = 1, - private readonly string $count = '1', + public readonly int $slotId = 1, + public readonly string $count = '1', ) { } } diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php index 25df0b83..f62ddb3d 100644 --- a/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php @@ -136,8 +136,9 @@ final class ShortUrlVisitsCountTracker $qb->forUpdate(); } - $resultSet = $qb->executeQuery()->fetchOne(); - $writeQb = ! $resultSet + $visitsCountId = $qb->executeQuery()->fetchOne(); + + $writeQb = ! $visitsCountId ? $conn->createQueryBuilder() ->insert('short_url_visits_counts') ->values([ @@ -145,18 +146,15 @@ final class ShortUrlVisitsCountTracker 'potential_bot' => ':potential_bot', 'slot_id' => ':slot_id', ]) - : $conn->createQueryBuilder() - ->update('short_url_visits_counts') - ->set('count', 'count + 1') - ->where($qb->expr()->and( - $qb->expr()->eq('short_url_id', ':short_url_id'), - $qb->expr()->eq('potential_bot', ':potential_bot'), - $qb->expr()->eq('slot_id', ':slot_id'), - )); - - $writeQb->setParameter('short_url_id', $shortUrlId) + ->setParameter('short_url_id', $shortUrlId) ->setParameter('potential_bot', $potentialBot ? '1' : '0') ->setParameter('slot_id', $slotId) - ->executeStatement(); + : $conn->createQueryBuilder() + ->update('short_url_visits_counts') + ->set('count', 'count + 1') + ->where($qb->expr()->eq('id', ':visits_count_id')) + ->setParameter('visits_count_id', $visitsCountId); + + $writeQb->executeStatement(); } } diff --git a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php new file mode 100644 index 00000000..bfb5616f --- /dev/null +++ b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php @@ -0,0 +1,76 @@ +repo = $this->getEntityManager()->getRepository(ShortUrlVisitsCount::class); + } + + #[Test] + public function createsNewEntriesWhenNoneExist(): void + { + $shortUrl = ShortUrl::createFake(); + $this->getEntityManager()->persist($shortUrl); + + $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $this->getEntityManager()->persist($visit); + $this->getEntityManager()->flush(); + + /** @var ShortUrlVisitsCount[] $result */ + $result = $this->repo->findBy(['shortUrl' => $shortUrl]); + + self::assertCount(1, $result); + self::assertEquals('1', $result[0]->count); + self::assertGreaterThanOrEqual(0, $result[0]->slotId); + self::assertLessThan(100, $result[0]->slotId); + } + + #[Test] + public function editsExistingEntriesWhenAlreadyExist(): void + { + $shortUrl = ShortUrl::createFake(); + $this->getEntityManager()->persist($shortUrl); + + for ($i = 0; $i < 100; $i++) { + $this->getEntityManager()->persist(new ShortUrlVisitsCount($shortUrl, slotId: $i)); + } + $this->getEntityManager()->flush(); + + $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $this->getEntityManager()->persist($visit); + $this->getEntityManager()->flush(); + + // Clear entity manager to force it to get fresh data from the database + // This is needed because the tracker inserts natively, bypassing the entity manager + $this->getEntityManager()->clear(); + + /** @var ShortUrlVisitsCount[] $result */ + $result = $this->repo->findBy(['shortUrl' => $shortUrl]); + $itemsWithCountBiggerThanOnce = array_values(array_filter( + $result, + static fn (ShortUrlVisitsCount $item) => ((int) $item->count) > 1, + )); + + self::assertCount(100, $result); + self::assertCount(1, $itemsWithCountBiggerThanOnce); + self::assertEquals('2', $itemsWithCountBiggerThanOnce[0]->count); + } +} diff --git a/phpunit-db.xml b/phpunit-db.xml index b883d8ca..3c5ffb64 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -20,6 +20,7 @@ ./module/*/src/Spec ./module/*/src/**/Spec ./module/*/src/**/**/Spec + ./module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9c85d2c4..4364c82c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -30,6 +30,7 @@ ./module/Core/src/Spec ./module/Core/src/**/Spec ./module/Core/src/**/**/Spec + ./module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php From c4fd3a74c564bc84a062bee972a54102e5e0976c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Mar 2024 16:10:56 +0100 Subject: [PATCH 30/64] Fix type hint in migration --- composer.json | 2 +- module/Core/migrations/Version20240318084804.php | 2 +- .../Visit/Listener/ShortUrlVisitsCountTrackerTest.php | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 2179a749..8d8f33f9 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "shlinkio/shlink-common": "^6.0", "shlinkio/shlink-config": "^3.0", "shlinkio/shlink-event-dispatcher": "^4.0", - "shlinkio/shlink-importer": "^5.3", + "shlinkio/shlink-importer": "^5.3.1", "shlinkio/shlink-installer": "^9.0", "shlinkio/shlink-ip-geolocation": "^4.0", "shlinkio/shlink-json": "^1.1", diff --git a/module/Core/migrations/Version20240318084804.php b/module/Core/migrations/Version20240318084804.php index 228fa474..6ff25fac 100644 --- a/module/Core/migrations/Version20240318084804.php +++ b/module/Core/migrations/Version20240318084804.php @@ -39,7 +39,7 @@ final class Version20240318084804 extends AbstractMigration } } - private function insertCount(string|int $shortUrlId, int $count, bool $potentialBot): void + private function insertCount(string|int $shortUrlId, string|int $count, bool $potentialBot): void { $this->connection->createQueryBuilder() ->insert('short_url_visits_counts') diff --git a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php index bfb5616f..29f5d5d1 100644 --- a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php @@ -40,7 +40,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase self::assertCount(1, $result); self::assertEquals('1', $result[0]->count); self::assertGreaterThanOrEqual(0, $result[0]->slotId); - self::assertLessThan(100, $result[0]->slotId); + self::assertLessThanOrEqual(100, $result[0]->slotId); } #[Test] @@ -49,7 +49,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase $shortUrl = ShortUrl::createFake(); $this->getEntityManager()->persist($shortUrl); - for ($i = 0; $i < 100; $i++) { + for ($i = 0; $i <= 100; $i++) { $this->getEntityManager()->persist(new ShortUrlVisitsCount($shortUrl, slotId: $i)); } $this->getEntityManager()->flush(); @@ -69,7 +69,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase static fn (ShortUrlVisitsCount $item) => ((int) $item->count) > 1, )); - self::assertCount(100, $result); + self::assertCount(101, $result); self::assertCount(1, $itemsWithCountBiggerThanOnce); self::assertEquals('2', $itemsWithCountBiggerThanOnce[0]->count); } From ab96297e58d92ea588d6c3b7d205aa289f4190bd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Mar 2024 17:06:18 +0100 Subject: [PATCH 31/64] Make sure VisitsTracker wraps as little operations as possible in the transaction --- module/Core/src/EventDispatcher/LocateVisit.php | 2 +- module/Core/src/Visit/VisitsTracker.php | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index aa6afed8..6f7fb7e8 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Throwable; -class LocateVisit +readonly class LocateVisit { public function __construct( private IpLocationResolverInterface $ipLocationResolver, diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index dd520e8f..f2e26493 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -71,12 +71,15 @@ readonly class VisitsTracker implements VisitsTrackerInterface return; } - $this->em->wrapInTransaction(function () use ($createVisit, $visitor): void { - $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); + $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); + + // Wrap persisting and flushing the visit in a transaction, so that the ShortUrlVisitsCountTracker performs + // changes inside that very same transaction atomically + $this->em->wrapInTransaction(function () use ($visit): void { $this->em->persist($visit); $this->em->flush(); - - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); }); + + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } From 1331b3f87c6e67156c2a3c5a1ca8ee7404e13e2a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Mar 2024 17:24:00 +0100 Subject: [PATCH 32/64] Fix RabbitMQ dev port --- config/autoload/rabbit.local.php.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/autoload/rabbit.local.php.dist b/config/autoload/rabbit.local.php.dist index b758528e..d19f82c6 100644 --- a/config/autoload/rabbit.local.php.dist +++ b/config/autoload/rabbit.local.php.dist @@ -7,7 +7,7 @@ return [ 'rabbitmq' => [ 'enabled' => true, 'host' => 'shlink_rabbitmq', - 'port' => '5673', + 'port' => '5672', 'user' => 'rabbit', 'password' => 'rabbit', ], From 8cb5d44dc9c877e47524e128a372e7b215333d26 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Mar 2024 17:27:49 +0100 Subject: [PATCH 33/64] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8157c47f..7b5f5cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. +* [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement when listing short URLs ordered by visits counts. + + This has been achieved by introducing a new table which tracks slotted visits counts. We can then `SUM` all counts for certain visit, avoiding `COUNT(visits)` aggregates which are less performant when there are a lot of visits. ### Deprecated * *Nothing* From 071cb9af2b0d419e45bcee2e1de4d581765fba83 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Mar 2024 19:17:37 +0100 Subject: [PATCH 34/64] Improve tags stats performance by using the new short_url_visits_counts table --- .../ShortUrl/Repository/ShortUrlListRepository.php | 2 +- module/Core/src/Tag/Repository/TagRepository.php | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 790a3dbb..323ecb10 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -85,7 +85,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') - ->where('1=1'); + ->where('1=1'); $dateRange = $filtering->dateRange; if ($dateRange?->startDate !== null) { diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 0f113776..c63d461d 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -76,19 +76,19 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $buildVisitsSubQb = static function (bool $excludeBots, string $aggregateAlias) use ($conn) { $visitsSubQb = $conn->createQueryBuilder(); - $commonJoinCondition = $visitsSubQb->expr()->eq('v.short_url_id', 's.id'); + $commonJoinCondition = $visitsSubQb->expr()->eq('sc.short_url_id', 'st.short_url_id'); $visitsJoin = ! $excludeBots ? $commonJoinCondition : $visitsSubQb->expr()->and( $commonJoinCondition, - $visitsSubQb->expr()->eq('v.potential_bot', $conn->quote('0')), + $visitsSubQb->expr()->eq('sc.potential_bot', $conn->quote('0')), )->__toString(); return $visitsSubQb - ->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias) - ->from('visits', 'v') - ->join('v', 'short_urls', 's', $visitsJoin) - ->join('s', 'short_urls_in_tags', 'st', $visitsSubQb->expr()->eq('st.short_url_id', 's.id')) + ->select('st.tag_id AS tag_id', 'SUM(sc.count) AS ' . $aggregateAlias) + ->from('short_url_visits_counts', 'sc') + ->join('sc', 'short_urls_in_tags', 'st', $visitsJoin) + ->join('sc', 'short_urls', 's', $visitsSubQb->expr()->eq('sc.short_url_id', 's.id')) ->groupBy('st.tag_id'); }; $allVisitsSubQb = $buildVisitsSubQb(false, 'visits'); From 90514c603fe397602549f7a3ecd9b4723c48acc2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 29 Mar 2024 09:35:54 +0100 Subject: [PATCH 35/64] Ensure ordering by title is consistent between database engines --- composer.json | 16 ++++++++-------- .../Repository/ShortUrlListRepository.php | 16 ++++++++++------ .../Rest/test-api/Action/ListShortUrlsTest.php | 2 +- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 8d8f33f9..8c32b1c9 100644 --- a/composer.json +++ b/composer.json @@ -124,15 +124,15 @@ "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov", - "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", - "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", - "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", - "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", + "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite -- $*", + "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite -- $*", + "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite -- $*", + "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite -- $*", "test:api": "bin/test/run-api-tests.sh", - "test:api:sqlite": "DB_DRIVER=sqlite composer test:api", - "test:api:mysql": "DB_DRIVER=mysql composer test:api", - "test:api:maria": "DB_DRIVER=maria composer test:api", - "test:api:mssql": "DB_DRIVER=mssql composer test:api", + "test:api:sqlite": "DB_DRIVER=sqlite composer test:api -- $*", + "test:api:mysql": "DB_DRIVER=mysql composer test:api -- $*", + "test:api:maria": "DB_DRIVER=maria composer test:api -- $*", + "test:api:mssql": "DB_DRIVER=mssql composer test:api -- $*", "test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov", "test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov", "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml", diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 323ecb10..e66bbdc2 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -46,6 +46,8 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh 'DISTINCT s AS shortUrl', '(' . $buildVisitsSubQuery('v', excludingBots: false) . ') AS ' . OrderableField::VISITS->value, '(' . $buildVisitsSubQuery('v2', excludingBots: true) . ') AS ' . OrderableField::NON_BOT_VISITS->value, + // This is added only to have a consistent order by title between database engines + 'COALESCE(s.title, \'\') AS title', ) ->setMaxResults($filtering->limit) ->setFirstResult($filtering->offset) @@ -62,15 +64,17 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void { $fieldName = $filtering->orderBy->field; - $order = $filtering->orderBy->direction; - - match (true) { + $direction = $filtering->orderBy->direction; + [$sort, $order] = match (true) { // With no explicit order by, fallback to dateCreated-DESC - $fieldName === null => $qb->orderBy('s.dateCreated', 'DESC'), + $fieldName === null => ['s.dateCreated', 'DESC'], $fieldName === OrderableField::VISITS->value, - $fieldName === OrderableField::NON_BOT_VISITS->value => $qb->orderBy($fieldName, $order), - default => $qb->orderBy('s.' . $fieldName, $order), + $fieldName === OrderableField::NON_BOT_VISITS->value, + $fieldName === OrderableField::TITLE->value => [$fieldName, $direction], + default => ['s.' . $fieldName, $direction], }; + + $qb->orderBy($sort, $order); } public function countList(ShortUrlsCountFiltering $filtering): int diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index c3b9b41e..e3fc49a6 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -201,12 +201,12 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['orderBy' => 'title-DESC'], [ + self::SHORT_URL_SHLINK_WITH_TITLE, 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, ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_CUSTOM_DOMAIN, From 55e2780f50619255114e5859a50ff823aa1d9eaa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Mar 2024 10:33:31 +0200 Subject: [PATCH 36/64] Load non-orphan visits overview via short url visits counts --- CHANGELOG.md | 2 +- ....Core.Visit.Entity.ShortUrlVisitsCount.php | 3 +- .../ShortUrlVisitsCountRepository.php | 31 +++++++++++++++++++ ...ShortUrlVisitsCountRepositoryInterface.php | 12 +++++++ .../src/Visit/Spec/CountOfNonOrphanVisits.php | 2 +- module/Core/src/Visit/VisitsStatsHelper.php | 12 ++++--- .../Core/test/Visit/VisitsStatsHelperTest.php | 16 +++++++--- 7 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 module/Core/src/Visit/Repository/ShortUrlVisitsCountRepository.php create mode 100644 module/Core/src/Visit/Repository/ShortUrlVisitsCountRepositoryInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5f5cff..0dfd04c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* *Nothing* +* Fix error when importing short URLs and visits from a Shlink 4.x instance ## [4.0.3] - 2024-03-15 diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php index d4a8546b..07977a50 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php @@ -11,7 +11,8 @@ use Doctrine\ORM\Mapping\ClassMetadata; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); - $builder->setTable(determineTableName('short_url_visits_counts', $emConfig)); + $builder->setTable(determineTableName('short_url_visits_counts', $emConfig)) + ->setCustomRepositoryClass(Visit\Repository\ShortUrlVisitsCountRepository::class); $builder->createField('id', Types::BIGINT) ->columnName('id') diff --git a/module/Core/src/Visit/Repository/ShortUrlVisitsCountRepository.php b/module/Core/src/Visit/Repository/ShortUrlVisitsCountRepository.php new file mode 100644 index 00000000..f07aab33 --- /dev/null +++ b/module/Core/src/Visit/Repository/ShortUrlVisitsCountRepository.php @@ -0,0 +1,31 @@ +getEntityManager()->createQueryBuilder(); + $qb->select('COALESCE(SUM(vc.count), 0)') + ->from(ShortUrlVisitsCount::class, 'vc') + ->join('vc.shortUrl', 's'); + + + if ($filtering->excludeBots) { + $qb->andWhere($qb->expr()->eq('vc.potentialBot', ':potentialBot')) + ->setParameter('potentialBot', false); + } + + $this->applySpecification($qb, $filtering->apiKey?->spec(), 's'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/module/Core/src/Visit/Repository/ShortUrlVisitsCountRepositoryInterface.php b/module/Core/src/Visit/Repository/ShortUrlVisitsCountRepositoryInterface.php new file mode 100644 index 00000000..fd966039 --- /dev/null +++ b/module/Core/src/Visit/Repository/ShortUrlVisitsCountRepositoryInterface.php @@ -0,0 +1,12 @@ +em->getRepository(Visit::class); + /** @var ShortUrlVisitsCountRepository $visitsCountRepo */ + $visitsCountRepo = $this->em->getRepository(ShortUrlVisitsCount::class); return new VisitsStats( - nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey)), + nonOrphanVisitsTotal: $visitsCountRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey)), orphanVisitsTotal: $visitsRepo->countOrphanVisits(new OrphanVisitsCountFiltering(apiKey: $apiKey)), - nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( + nonOrphanVisitsNonBots: $visitsCountRepo->countNonOrphanVisits( new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), orphanVisitsNonBots: $visitsRepo->countOrphanVisits( diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index f6bb5464..e109852a 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -22,6 +22,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; +use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -31,6 +32,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -54,9 +56,9 @@ class VisitsStatsHelperTest extends TestCase #[Test, DataProvider('provideCounts')] public function returnsExpectedVisitsStats(int $expectedCount, ?ApiKey $apiKey): void { - $repo = $this->createMock(VisitRepository::class); $callCount = 0; - $repo->expects($this->exactly(2))->method('countNonOrphanVisits')->willReturnCallback( + $visitsCountRepo = $this->createMock(ShortUrlVisitsCountRepository::class); + $visitsCountRepo->expects($this->exactly(2))->method('countNonOrphanVisits')->willReturnCallback( function (VisitsCountFiltering $options) use ($expectedCount, $apiKey, &$callCount) { Assert::assertEquals($callCount !== 0, $options->excludeBots); Assert::assertEquals($apiKey, $options->apiKey); @@ -65,10 +67,16 @@ class VisitsStatsHelperTest extends TestCase return $expectedCount * 3; }, ); - $repo->expects($this->exactly(2))->method('countOrphanVisits')->with( + + $visitsRepo = $this->createMock(VisitRepository::class); + $visitsRepo->expects($this->exactly(2))->method('countOrphanVisits')->with( $this->isInstanceOf(VisitsCountFiltering::class), )->willReturn($expectedCount); - $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); + + $this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([ + [Visit::class, $visitsRepo], + [ShortUrlVisitsCount::class, $visitsCountRepo], + ]); $stats = $this->helper->getVisitsStats($apiKey); From ab6fa490e5ac36c923411e2d26a8b837ed9e27ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Mar 2024 12:37:22 +0200 Subject: [PATCH 37/64] Test ShortUrlVisitsCountRepository via VisitRepositoryTest --- .../Visit/Repository/VisitRepositoryTest.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 90496e1e..d14e7078 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -21,6 +22,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -36,11 +38,15 @@ use const STR_PAD_LEFT; class VisitRepositoryTest extends DatabaseTestCase { private VisitRepository $repo; + private ShortUrlVisitsCountRepository $countRepo; private PersistenceShortUrlRelationResolver $relationResolver; protected function setUp(): void { $this->repo = $this->getEntityManager()->getRepository(Visit::class); + // Testing the ShortUrlVisitsCountRepository in this very same test, helps checking the fact that results should + // match what VisitRepository returns + $this->countRepo = $this->getEntityManager()->getRepository(ShortUrlVisitsCount::class); $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); } @@ -308,9 +314,15 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering())); + self::assertEquals(4 + 5 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering())); self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1))); + self::assertEquals(4, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1))); self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2))); + self::assertEquals(5 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2))); self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $domainApiKey))); + self::assertEquals(4 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering( + apiKey: $domainApiKey, + ))); self::assertEquals(0, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( apiKey: $noOrphanVisitsApiKey, ))); @@ -323,7 +335,12 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-07')->startOfDay(), ), false, $apiKey2))); - self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(null, true, $apiKey2))); + self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits( + new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2), + )); + self::assertEquals(3 + 5, $this->countRepo->countNonOrphanVisits( + new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2), + )); self::assertEquals(4, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); self::assertEquals(3, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(excludeBots: true))); } From 6e82509964d4fc69cb2e3afb48183e56a6fad388 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Mar 2024 12:39:37 +0200 Subject: [PATCH 38/64] Update changelog --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfd04c8..f6ec4f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. -* [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement when listing short URLs ordered by visits counts. +* [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement in some endpoints which involve counting visits: - This has been achieved by introducing a new table which tracks slotted visits counts. We can then `SUM` all counts for certain visit, avoiding `COUNT(visits)` aggregates which are less performant when there are a lot of visits. + * listing short URLs ordered by visits counts. + * loading tags with stats. + * visits overview. + + This has been achieved by introducing a new table which tracks slotted visits counts. We can then `SUM` all counts for certain short URL, avoiding `COUNT(visits)` aggregates which are much less performant when there are a lot of visits. ### Deprecated * *Nothing* From b50547d868004e3bedc9ad4bd87deffe8b331782 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Mar 2024 13:18:44 +0200 Subject: [PATCH 39/64] Create new orphan_visits_counts table --- .../Core/migrations/Version20240331111103.php | 56 +++++++++++++++++++ .../Core/migrations/Version20240331111447.php | 45 +++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 module/Core/migrations/Version20240331111103.php create mode 100644 module/Core/migrations/Version20240331111447.php diff --git a/module/Core/migrations/Version20240331111103.php b/module/Core/migrations/Version20240331111103.php new file mode 100644 index 00000000..ec0a5087 --- /dev/null +++ b/module/Core/migrations/Version20240331111103.php @@ -0,0 +1,56 @@ +skipIf($schema->hasTable('orphan_visits_counts')); + + $table = $schema->createTable('orphan_visits_counts'); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('potential_bot', Types::BOOLEAN, ['default' => false]); + + $table->addColumn('slot_id', Types::INTEGER, [ + 'unsigned' => true, + 'notnull' => true, + 'default' => 1, + ]); + + $table->addColumn('count', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + 'default' => 1, + ]); + + $table->addUniqueIndex(['potential_bot', 'slot_id'], 'UQ_slot'); + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable('orphan_visits_counts')); + $schema->dropTable('orphan_visits_counts'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/migrations/Version20240331111447.php b/module/Core/migrations/Version20240331111447.php new file mode 100644 index 00000000..333656c4 --- /dev/null +++ b/module/Core/migrations/Version20240331111447.php @@ -0,0 +1,45 @@ +connection->createQueryBuilder(); + $visitsQb->select('COUNT(id)') + ->from('visits') + ->where($visitsQb->expr()->isNull('short_url_id')) + ->andWhere($visitsQb->expr()->eq('potential_bot', ':potential_bot')); + + $botsCount = $visitsQb->setParameter('potential_bot', '1')->executeQuery()->fetchOne(); + $nonBotsCount = $visitsQb->setParameter('potential_bot', '0')->executeQuery()->fetchOne(); + + if ($botsCount > 0) { + $this->insertCount($botsCount, potentialBot: true); + } + if ($nonBotsCount > 0) { + $this->insertCount($nonBotsCount, potentialBot: false); + } + } + + private function insertCount(string|int $count, bool $potentialBot): void + { + $this->connection->createQueryBuilder() + ->insert('orphan_visits_counts') + ->values([ + 'count' => ':count', + 'potential_bot' => ':potential_bot', + ]) + ->setParameters([ + 'count' => $count, + 'potential_bot' => $potentialBot ? '1' : '0', + ]) + ->executeStatement(); + } +} From 284b28e8d97c5630d4fdadca28aa3baef63089cc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Mar 2024 13:51:03 +0200 Subject: [PATCH 40/64] Track short URL title as document title when sending visits to matomo --- CHANGELOG.md | 2 ++ docker-compose.yml | 2 +- .../EventDispatcher/Matomo/SendVisitToMatomo.php | 16 ++++++++-------- module/Core/src/ShortUrl/Entity/ShortUrl.php | 5 +++++ .../Matomo/SendVisitToMatomoTest.php | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ec4f68..07d4e3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Previously, this was exposed only for orphan visits, since this can be an arbitrary value for those. +* [#2077](https://github.com/shlinkio/shlink/issues/2077) When sending visits to Matomo, the short URL title is now used as document title in matomo. + ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. * [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement in some endpoints which involve counting visits: diff --git a/docker-compose.yml b/docker-compose.yml index ccc5fc2d..5bccfd48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -169,7 +169,7 @@ services: shlink_matomo: container_name: shlink_matomo - image: matomo:4.15-apache + image: matomo:5.0-apache ports: - "8003:80" volumes: diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index be288fd0..d0fa1035 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -13,14 +13,14 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Throwable; -class SendVisitToMatomo +readonly class SendVisitToMatomo { public function __construct( - private readonly EntityManagerInterface $em, - private readonly LoggerInterface $logger, - private readonly ShortUrlStringifier $shortUrlStringifier, - private readonly MatomoOptions $matomoOptions, - private readonly MatomoTrackerBuilderInterface $trackerBuilder, + private EntityManagerInterface $em, + private LoggerInterface $logger, + private ShortUrlStringifier $shortUrlStringifier, + private MatomoOptions $matomoOptions, + private MatomoTrackerBuilderInterface $trackerBuilder, ) { } @@ -69,8 +69,8 @@ class SendVisitToMatomo $tracker->setCustomTrackingParameter('orphan', 'true'); } - // Send empty document title to avoid different actions to be created by matomo - $tracker->doTrackPageView(''); + // Send the short URL title or an empty document title to avoid different actions to be created by matomo + $tracker->doTrackPageView($visit->shortUrl?->title() ?? ''); } catch (Throwable $e) { // Capture all exceptions to make sure this does not interfere with the regular execution $this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]); diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index ac21b34b..ed97f9f5 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -192,6 +192,11 @@ class ShortUrl extends AbstractEntity return $this->forwardQuery; } + public function title(): ?string + { + return $this->title; + } + public function reachedVisits(int $visitsAmount): bool { return count($this->visits) >= $visitsAmount; diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index d821bcbb..cf9f4005 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -72,7 +72,7 @@ class SendVisitToMatomoTest extends TestCase $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); - $tracker->expects($this->once())->method('doTrackPageView')->with(''); + $tracker->expects($this->once())->method('doTrackPageView')->with($visit->shortUrl?->title() ?? ''); if ($visit->isOrphan()) { $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ From d090260b175601fc194a4b0c30775b34c179326e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Apr 2024 10:22:51 +0200 Subject: [PATCH 41/64] Track orphan visits counts --- config/autoload/entity-manager.global.php | 5 +- module/Core/config/dependencies.config.php | 1 + ...nk.Core.Visit.Entity.OrphanVisitsCount.php | 41 +++++ .../src/Visit/Entity/OrphanVisitsCount.php | 17 ++ .../Listener/OrphanVisitsCountTracker.php | 145 ++++++++++++++++++ .../OrphanVisitsCountRepository.php | 31 ++++ .../OrphanVisitsCountRepositoryInterface.php | 12 ++ module/Core/src/Visit/VisitsStatsHelper.php | 13 +- .../Listener/OrphanVisitsCountTrackerTest.php | 69 +++++++++ .../Visit/Repository/VisitRepositoryTest.php | 16 +- .../Core/test/Visit/VisitsStatsHelperTest.php | 8 +- phpunit-db.xml | 2 +- phpunit.xml.dist | 2 +- 13 files changed, 349 insertions(+), 13 deletions(-) create mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.OrphanVisitsCount.php create mode 100644 module/Core/src/Visit/Entity/OrphanVisitsCount.php create mode 100644 module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php create mode 100644 module/Core/src/Visit/Repository/OrphanVisitsCountRepository.php create mode 100644 module/Core/src/Visit/Repository/OrphanVisitsCountRepositoryInterface.php create mode 100644 module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 84233915..c3aa6632 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Doctrine\ORM\Events; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Visit\Listener\OrphanVisitsCountTracker; use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountTracker; use function Shlinkio\Shlink\Core\ArrayUtils\contains; @@ -63,8 +64,8 @@ return (static function (): array { 'load_mappings_using_functional_style' => true, 'default_repository_classname' => EntitySpecificationRepository::class, 'listeners' => [ - Events::onFlush => [ShortUrlVisitsCountTracker::class], - Events::postFlush => [ShortUrlVisitsCountTracker::class], + Events::onFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class], + Events::postFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class], ], ], 'connection' => $resolveConnection(), diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 951c7b52..5437555b 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -77,6 +77,7 @@ return [ Visit\Entity\Visit::class, ], Visit\Listener\ShortUrlVisitsCountTracker::class => InvokableFactory::class, + Visit\Listener\OrphanVisitsCountTracker::class => InvokableFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.OrphanVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.OrphanVisitsCount.php new file mode 100644 index 00000000..dbdf9e9a --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.OrphanVisitsCount.php @@ -0,0 +1,41 @@ +setTable(determineTableName('orphan_visits_counts', $emConfig)) + ->setCustomRepositoryClass(Visit\Repository\OrphanVisitsCountRepository::class); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + $builder->createField('potentialBot', Types::BOOLEAN) + ->columnName('potential_bot') + ->option('default', false) + ->build(); + + $builder->createField('count', Types::BIGINT) + ->columnName('count') + ->option('unsigned', true) + ->option('default', 1) + ->build(); + + $builder->createField('slotId', Types::INTEGER) + ->columnName('slot_id') + ->option('unsigned', true) + ->build(); + + $builder->addUniqueConstraint(['potential_bot', 'slot_id'], 'UQ_slot'); +}; diff --git a/module/Core/src/Visit/Entity/OrphanVisitsCount.php b/module/Core/src/Visit/Entity/OrphanVisitsCount.php new file mode 100644 index 00000000..8199b174 --- /dev/null +++ b/module/Core/src/Visit/Entity/OrphanVisitsCount.php @@ -0,0 +1,17 @@ +entitiesToBeCreated = $args->getObjectManager()->getUnitOfWork()->getScheduledEntityInsertions(); + } + + /** + * @throws Exception + */ + public function postFlush(PostFlushEventArgs $args): void + { + $em = $args->getObjectManager(); + $entitiesToBeCreated = $this->entitiesToBeCreated; + + // Reset tracked entities until next flush operation + $this->entitiesToBeCreated = []; + + foreach ($entitiesToBeCreated as $entity) { + $this->trackVisitCount($em, $entity); + } + } + + /** + * @throws Exception + */ + private function trackVisitCount(EntityManagerInterface $em, object $entity): void + { + // This is not an orphan visit + if (! $entity instanceof Visit || ! $entity->isOrphan()) { + return; + } + $visit = $entity; + + $isBot = $visit->potentialBot; + $conn = $em->getConnection(); + $platformClass = $conn->getDatabasePlatform(); + + match ($platformClass::class) { + PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $isBot), + SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $isBot), + default => $this->incrementForMySQL($conn, $isBot), + }; + } + + /** + * @throws Exception + */ + private function incrementForMySQL(Connection $conn, bool $potentialBot): void + { + $this->incrementWithPreparedStatement($conn, $potentialBot, <<incrementWithPreparedStatement($conn, $potentialBot, <<prepare($query); + $statement->bindValue('potential_bot', $potentialBot ? 1 : 0); + $statement->executeStatement(); + } + + /** + * @throws Exception + */ + private function incrementForOthers(Connection $conn, bool $potentialBot): void + { + $slotId = rand(1, 100); + + // For engines without a specific UPSERT syntax, do a regular locked select followed by an insert or update + $qb = $conn->createQueryBuilder(); + $qb->select('id') + ->from('orphan_visits_counts') + ->where($qb->expr()->and( + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )) + ->setParameter('potential_bot', $potentialBot ? '1' : '0') + ->setParameter('slot_id', $slotId) + ->setMaxResults(1); + + if ($conn->getDatabasePlatform()::class === SQLServerPlatform::class) { + $qb->forUpdate(); + } + + $visitsCountId = $qb->executeQuery()->fetchOne(); + + $writeQb = ! $visitsCountId + ? $conn->createQueryBuilder() + ->insert('orphan_visits_counts') + ->values([ + 'potential_bot' => ':potential_bot', + 'slot_id' => ':slot_id', + ]) + ->setParameter('potential_bot', $potentialBot ? '1' : '0') + ->setParameter('slot_id', $slotId) + : $conn->createQueryBuilder() + ->update('orphan_visits_counts') + ->set('count', 'count + 1') + ->where($qb->expr()->eq('id', ':visits_count_id')) + ->setParameter('visits_count_id', $visitsCountId); + + $writeQb->executeStatement(); + } +} diff --git a/module/Core/src/Visit/Repository/OrphanVisitsCountRepository.php b/module/Core/src/Visit/Repository/OrphanVisitsCountRepository.php new file mode 100644 index 00000000..d5e7670c --- /dev/null +++ b/module/Core/src/Visit/Repository/OrphanVisitsCountRepository.php @@ -0,0 +1,31 @@ +apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { + return 0; + } + + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('COALESCE(SUM(vc.count), 0)') + ->from(OrphanVisitsCount::class, 'vc'); + + if ($filtering->excludeBots) { + $qb->andWhere($qb->expr()->eq('vc.potentialBot', ':potentialBot')) + ->setParameter('potentialBot', false); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/module/Core/src/Visit/Repository/OrphanVisitsCountRepositoryInterface.php b/module/Core/src/Visit/Repository/OrphanVisitsCountRepositoryInterface.php new file mode 100644 index 00000000..adbbe076 --- /dev/null +++ b/module/Core/src/Visit/Repository/OrphanVisitsCountRepositoryInterface.php @@ -0,0 +1,12 @@ +em->getRepository(Visit::class); + /** @var OrphanVisitsCountRepository $orphanVisitsCountRepo */ + $orphanVisitsCountRepo = $this->em->getRepository(OrphanVisitsCount::class); /** @var ShortUrlVisitsCountRepository $visitsCountRepo */ $visitsCountRepo = $this->em->getRepository(ShortUrlVisitsCount::class); return new VisitsStats( nonOrphanVisitsTotal: $visitsCountRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey)), - orphanVisitsTotal: $visitsRepo->countOrphanVisits(new OrphanVisitsCountFiltering(apiKey: $apiKey)), + orphanVisitsTotal: $orphanVisitsCountRepo->countOrphanVisits( + new OrphanVisitsCountFiltering(apiKey: $apiKey), + ), nonOrphanVisitsNonBots: $visitsCountRepo->countNonOrphanVisits( new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), - orphanVisitsNonBots: $visitsRepo->countOrphanVisits( + orphanVisitsNonBots: $orphanVisitsCountRepo->countOrphanVisits( new OrphanVisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), ); diff --git a/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php new file mode 100644 index 00000000..20302c75 --- /dev/null +++ b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php @@ -0,0 +1,69 @@ +repo = $this->getEntityManager()->getRepository(OrphanVisitsCount::class); + } + + #[Test] + public function createsNewEntriesWhenNoneExist(): void + { + $visit = Visit::forBasePath(Visitor::emptyInstance()); + $this->getEntityManager()->persist($visit); + $this->getEntityManager()->flush(); + + /** @var OrphanVisitsCount[] $result */ + $result = $this->repo->findAll(); + + self::assertCount(1, $result); + self::assertEquals('1', $result[0]->count); + self::assertGreaterThanOrEqual(0, $result[0]->slotId); + self::assertLessThanOrEqual(100, $result[0]->slotId); + } + + #[Test] + public function editsExistingEntriesWhenAlreadyExist(): void + { + for ($i = 0; $i <= 100; $i++) { + $this->getEntityManager()->persist(new OrphanVisitsCount(slotId: $i)); + } + $this->getEntityManager()->flush(); + + $visit = Visit::forRegularNotFound(Visitor::emptyInstance()); + $this->getEntityManager()->persist($visit); + $this->getEntityManager()->flush(); + + // Clear entity manager to force it to get fresh data from the database + // This is needed because the tracker inserts natively, bypassing the entity manager + $this->getEntityManager()->clear(); + + /** @var OrphanVisitsCount[] $result */ + $result = $this->repo->findAll(); + $itemsWithCountBiggerThanOnce = array_values(array_filter( + $result, + static fn (OrphanVisitsCount $item) => ((int) $item->count) > 1, + )); + + self::assertCount(101, $result); + self::assertCount(1, $itemsWithCountBiggerThanOnce); + self::assertEquals('2', $itemsWithCountBiggerThanOnce[0]->count); + } +} diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index d14e7078..1506dd1a 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; @@ -22,6 +23,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; @@ -39,14 +41,18 @@ class VisitRepositoryTest extends DatabaseTestCase { private VisitRepository $repo; private ShortUrlVisitsCountRepository $countRepo; + private OrphanVisitsCountRepository $orphanCountRepo; private PersistenceShortUrlRelationResolver $relationResolver; protected function setUp(): void { $this->repo = $this->getEntityManager()->getRepository(Visit::class); - // Testing the ShortUrlVisitsCountRepository in this very same test, helps checking the fact that results should + + // Testing the visits count repositories in this very same test, helps checking the fact that results should // match what VisitRepository returns $this->countRepo = $this->getEntityManager()->getRepository(ShortUrlVisitsCount::class); + $this->orphanCountRepo = $this->getEntityManager()->getRepository(OrphanVisitsCount::class); + $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); } @@ -326,6 +332,9 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(0, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( apiKey: $noOrphanVisitsApiKey, ))); + self::assertEquals(0, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering( + apiKey: $noOrphanVisitsApiKey, + ))); self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); @@ -342,7 +351,11 @@ class VisitRepositoryTest extends DatabaseTestCase new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2), )); self::assertEquals(4, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); + self::assertEquals(4, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering())); self::assertEquals(3, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(excludeBots: true))); + self::assertEquals(3, $this->orphanCountRepo->countOrphanVisits( + new OrphanVisitsCountFiltering(excludeBots: true), + )); } #[Test] @@ -432,6 +445,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(18, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); + self::assertEquals(18, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering())); self::assertEquals(18, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(DateRange::allTime()))); self::assertEquals(9, $this->repo->countOrphanVisits( new OrphanVisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index e109852a..c1aa0747 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -22,6 +22,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; +use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; @@ -32,6 +33,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; @@ -68,13 +70,13 @@ class VisitsStatsHelperTest extends TestCase }, ); - $visitsRepo = $this->createMock(VisitRepository::class); - $visitsRepo->expects($this->exactly(2))->method('countOrphanVisits')->with( + $orphanVisitsCountRepo = $this->createMock(OrphanVisitsCountRepository::class); + $orphanVisitsCountRepo->expects($this->exactly(2))->method('countOrphanVisits')->with( $this->isInstanceOf(VisitsCountFiltering::class), )->willReturn($expectedCount); $this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([ - [Visit::class, $visitsRepo], + [OrphanVisitsCount::class, $orphanVisitsCountRepo], [ShortUrlVisitsCount::class, $visitsCountRepo], ]); diff --git a/phpunit-db.xml b/phpunit-db.xml index 3c5ffb64..17e748b8 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -20,7 +20,7 @@ ./module/*/src/Spec ./module/*/src/**/Spec ./module/*/src/**/**/Spec - ./module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php + ./module/Core/src/Visit/Listener/*.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4364c82c..30f2286d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -30,7 +30,7 @@ ./module/Core/src/Spec ./module/Core/src/**/Spec ./module/Core/src/**/**/Spec - ./module/Core/src/Visit/Listener/ShortUrlVisitsCountTracker.php + ./module/Core/src/Visit/Listener/*.php From f92a720d63b5e8aa7a425bd60d3a0747244d2879 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Apr 2024 09:06:43 +0200 Subject: [PATCH 42/64] Use short_url_visits_counts table when excluding short URLs which reached max visits --- .../Core/src/ShortUrl/Repository/ShortUrlListRepository.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index e66bbdc2..b639ace4 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -147,7 +147,10 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $qb->expr()->isNull('s.maxVisits'), $qb->expr()->gt( 's.maxVisits', - sprintf('(SELECT COUNT(innerV.id) FROM %s as innerV WHERE innerV.shortUrl=s)', Visit::class), + sprintf( + '(SELECT COALESCE(SUM(vc.count), 0) FROM %s as vc WHERE vc.shortUrl=s)', + ShortUrlVisitsCount::class, + ), ), )); } From fd882834d30f53b06ec103e40fe1894a4198245f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Apr 2024 09:41:05 +0200 Subject: [PATCH 43/64] Create repository to handle expired short URLs deletion --- module/Core/config/dependencies.config.php | 4 + .../Model/ExpiredShortUrlsConditions.php | 25 +++++ .../Repository/ExpiredShortUrlsRepository.php | 77 ++++++++++++++ .../ExpiredShortUrlsRepositoryInterface.php | 20 ++++ .../DeleteExpiredShortUrlsRepositoryTest.php | 100 ++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php create mode 100644 module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php create mode 100644 module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php create mode 100644 module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 5437555b..c78ce31a 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -57,6 +57,10 @@ return [ EntityRepositoryFactory::class, ShortUrl\Entity\ShortUrl::class, ], + ShortUrl\Repository\ExpiredShortUrlsRepository::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], Tag\TagService::class => ConfigAbstractFactory::class, diff --git a/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php new file mode 100644 index 00000000..565b9e98 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php @@ -0,0 +1,25 @@ +pastValidUntil || $this->maxVisitsReached; + } +} diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php new file mode 100644 index 00000000..6d8aa0df --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php @@ -0,0 +1,77 @@ +getEntityManager()->createQueryBuilder(); + $qb->delete(ShortUrl::class, 's'); + + return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->execute()); + } + + /** + * @inheritDoc + */ + public function dryCount(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('COUNT(s.id)') + ->from(ShortUrl::class, 's'); + + return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->getSingleScalarResult()); + } + + /** + * @param callable(): int $getResultFromQueryBuilder + */ + private function applyConditions( + QueryBuilder $qb, + ExpiredShortUrlsConditions $conditions, + callable $getResultFromQueryBuilder, + ): int { + if (! $conditions->hasConditions()) { + return 0; + } + + if ($conditions->pastValidUntil) { + $qb + ->where($qb->expr()->andX( + $qb->expr()->isNotNull('s.validUntil'), + $qb->expr()->lt('s.validUntil', ':now'), + )) + ->setParameter('now', Chronos::now()->toDateTimeString()); + } + + if ($conditions->maxVisitsReached) { + $qb->orWhere($qb->expr()->andX( + $qb->expr()->isNotNull('s.maxVisits'), + $qb->expr()->lte( + 's.maxVisits', + sprintf( + '(SELECT COALESCE(SUM(vc.count), 0) FROM %s as vc WHERE vc.shortUrl=s)', + ShortUrlVisitsCount::class, + ), + ), + )); + } + + return $getResultFromQueryBuilder(); + } +} diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php new file mode 100644 index 00000000..e82c3e43 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php @@ -0,0 +1,20 @@ +getEntityManager(); + $this->repository = new ExpiredShortUrlsRepository($em, $em->getClassMetadata(ShortUrl::class)); + } + + #[Test] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: false), 0])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: false), 7])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: true), 6])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: true), 9])] + public function deletesExpectedAmountOfShortUrls( + ExpiredShortUrlsConditions $conditions, + int $expectedDeletedShortUrls, + ): void { + $createdShortUrls = $this->createDataSet(); + + self::assertEquals($expectedDeletedShortUrls, $this->repository->delete($conditions)); + self::assertEquals( + $createdShortUrls - $expectedDeletedShortUrls, + $this->getEntityManager()->getRepository(ShortUrl::class)->count(), + ); + } + + #[Test] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: false), 0])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: false), 7])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: true), 6])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: true), 9])] + public function countsExpectedAmountOfShortUrls( + ExpiredShortUrlsConditions $conditions, + int $expectedShortUrlsCount, + ): void { + $createdShortUrls = $this->createDataSet(); + + self::assertEquals($expectedShortUrlsCount, $this->repository->dryCount($conditions)); + self::assertEquals($createdShortUrls, $this->getEntityManager()->getRepository(ShortUrl::class)->count()); + } + + private function createDataSet(): int + { + // Create some non-expired short URLs + $this->createShortUrls(5); + $this->createShortUrls(2, [ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->addDays(1)->toAtomString()]); + $this->createShortUrls(3, [ShortUrlInputFilter::MAX_VISITS => 4], visitsPerShortUrl: 2); + + // Create some short URLs with a valid date in the past + $this->createShortUrls(3, [ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->subDays(1)->toAtomString()]); + + // Create some short URLs which reached the max amount of visits + $this->createShortUrls(2, [ShortUrlInputFilter::MAX_VISITS => 3], visitsPerShortUrl: 3); + + // Create some short URLs with a valid date in the past which also reached the max amount of visits + $this->createShortUrls(4, [ + ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->subDays(1)->toAtomString(), + ShortUrlInputFilter::MAX_VISITS => 3, + ], visitsPerShortUrl: 4); + + $this->getEntityManager()->flush(); + + return 5 + 2 + 3 + 3 + 2 + 4; + } + + private function createShortUrls(int $amountOfShortUrls, array $metadata = [], int $visitsPerShortUrl = 0): void + { + for ($i = 0; $i < $amountOfShortUrls; $i++) { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://shlink.io', + ...$metadata, + ])); + $this->getEntityManager()->persist($shortUrl); + + for ($j = 0; $j < $visitsPerShortUrl; $j++) { + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + } + } + } +} From f2371e8a80270dc1982a66427270ab7974c2e3ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Apr 2024 18:57:09 +0200 Subject: [PATCH 44/64] Add command to delete expired short URLs --- module/CLI/config/cli.config.php | 2 + module/CLI/config/dependencies.config.php | 2 + .../DeleteExpiredShortUrlsCommand.php | 75 +++++++++++++++++++ module/Core/config/dependencies.config.php | 1 + .../src/ShortUrl/DeleteShortUrlService.php | 21 +++++- .../DeleteShortUrlServiceInterface.php | 11 +++ .../Model/ExpiredShortUrlsConditions.php | 8 -- .../Repository/ExpiredShortUrlsRepository.php | 4 +- .../ExpiredShortUrlsRepositoryInterface.php | 4 +- .../Repository/ShortUrlListRepository.php | 1 - .../ShortUrl/DeleteShortUrlServiceTest.php | 29 ++++++- 11 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 94237c15..63b2de6f 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -14,6 +14,8 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class, Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class, + Command\ShortUrl\DeleteExpiredShortUrlsCommand::NAME => + Command\ShortUrl\DeleteExpiredShortUrlsCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 282a1db5..875c8226 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -45,6 +45,7 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => ConfigAbstractFactory::class, Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, @@ -96,6 +97,7 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class], Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class], + Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class], Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class], Command\Visit\LocateVisitsCommand::class => [ diff --git a/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php new file mode 100644 index 00000000..109beff7 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php @@ -0,0 +1,75 @@ +setName(self::NAME) + ->setDescription( + 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past', + ) + ->addOption( + 'evaluate-max-visits', + mode: InputOption::VALUE_NONE, + description: 'Also take into consideration short URLs which have reached their max amount of visits.', + ) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation') + ->addOption( + 'dry-run', + mode: InputOption::VALUE_NONE, + description: 'Delete short URLs with no confirmation', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = $input->getOption('force') || ! $input->isInteractive(); + $dryRun = $input->getOption('dry-run'); + $conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits')); + + if (! $force && ! $dryRun) { + $io->warning([ + 'Careful!', + 'You are about to perform a destructive operation that can result in deleted short URLs and visits.', + 'This action cannot be undone. Proceed at your own risk', + ]); + if (! $io->confirm('Continue?', default: false)) { + return ExitCode::EXIT_WARNING; + } + } + + if ($dryRun) { + $result = $this->deleteShortUrlService->countExpiredShortUrls($conditions); + $io->success(sprintf('There are %s expired short URLs matching provided conditions', $result)); + return ExitCode::EXIT_SUCCESS; + } + + $result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions); + $io->success(sprintf('%s expired short URLs have been deleted', $result)); + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index c78ce31a..5f3d8fae 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -151,6 +151,7 @@ return [ 'em', Options\DeleteShortUrlsOptions::class, ShortUrl\ShortUrlResolver::class, + ShortUrl\Repository\ExpiredShortUrlsRepository::class, ], ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class], ShortUrl\ShortUrlVisitsDeleter::class => [ diff --git a/module/Core/src/ShortUrl/DeleteShortUrlService.php b/module/Core/src/ShortUrl/DeleteShortUrlService.php index 2a39e695..b65381aa 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlService.php @@ -8,15 +8,18 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class DeleteShortUrlService implements DeleteShortUrlServiceInterface +readonly class DeleteShortUrlService implements DeleteShortUrlServiceInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly DeleteShortUrlsOptions $deleteShortUrlsOptions, - private readonly ShortUrlResolverInterface $urlResolver, + private EntityManagerInterface $em, + private DeleteShortUrlsOptions $deleteShortUrlsOptions, + private ShortUrlResolverInterface $urlResolver, + private ExpiredShortUrlsRepositoryInterface $expiredShortUrlsRepository, ) { } @@ -47,4 +50,14 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface $this->deleteShortUrlsOptions->visitsThreshold, ); } + + public function deleteExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int + { + return $this->expiredShortUrlsRepository->delete($conditions); + } + + public function countExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int + { + return $this->expiredShortUrlsRepository->dryCount($conditions); + } } diff --git a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php index 0a7420f1..32eaffa1 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\Exception; +use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -19,4 +20,14 @@ interface DeleteShortUrlServiceInterface bool $ignoreThreshold = false, ?ApiKey $apiKey = null, ): void; + + /** + * Deletes short URLs that are considered expired based on provided conditions + */ + public function deleteExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int; + + /** + * Counts short URLs that are considered expired based on provided conditions, without really deleting them + */ + public function countExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int; } diff --git a/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php index 565b9e98..d4f0c063 100644 --- a/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php +++ b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php @@ -10,14 +10,6 @@ final readonly class ExpiredShortUrlsConditions { } - public static function fromQuery(array $query): self - { - return new self( - pastValidUntil: (bool) ($query['pastValidUntil'] ?? true), - maxVisitsReached: (bool) ($query['maxVisitsReached'] ?? true), - ); - } - public function hasConditions(): bool { return $this->pastValidUntil || $this->maxVisitsReached; diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php index 6d8aa0df..0b796971 100644 --- a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php @@ -18,7 +18,7 @@ class ExpiredShortUrlsRepository extends EntitySpecificationRepository implement /** * @inheritDoc */ - public function delete(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int + public function delete(ExpiredShortUrlsConditions $conditions): int { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->delete(ShortUrl::class, 's'); @@ -29,7 +29,7 @@ class ExpiredShortUrlsRepository extends EntitySpecificationRepository implement /** * @inheritDoc */ - public function dryCount(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int + public function dryCount(ExpiredShortUrlsConditions $conditions): int { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->select('COUNT(s.id)') diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php index e82c3e43..96032065 100644 --- a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php @@ -11,10 +11,10 @@ interface ExpiredShortUrlsRepositoryInterface /** * Delete expired short URLs matching provided conditions */ - public function delete(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int; + public function delete(ExpiredShortUrlsConditions $conditions): int; /** * Count how many expired short URLs would be deleted for provided conditions */ - public function dryCount(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int; + public function dryCount(ExpiredShortUrlsConditions $conditions): int; } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index b639ace4..8aac9b73 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index 3ac9897c..4788818e 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlService; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepository; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -26,6 +28,7 @@ class DeleteShortUrlServiceTest extends TestCase { private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlResolverInterface $urlResolver; + private MockObject & ExpiredShortUrlsRepository $expiredShortUrlsRepository; private string $shortCode; protected function setUp(): void @@ -39,6 +42,8 @@ class DeleteShortUrlServiceTest extends TestCase $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl); + + $this->expiredShortUrlsRepository = $this->createMock(ExpiredShortUrlsRepository::class); } #[Test] @@ -94,11 +99,33 @@ class DeleteShortUrlServiceTest extends TestCase $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); } + #[Test] + public function deleteExpiredShortUrlsDelegatesToRepository(): void + { + $conditions = new ExpiredShortUrlsConditions(); + $this->expiredShortUrlsRepository->expects($this->once())->method('delete')->with($conditions)->willReturn(5); + + $result = $this->createService()->deleteExpiredShortUrls($conditions); + + self::assertEquals(5, $result); + } + + #[Test] + public function countExpiredShortUrlsDelegatesToRepository(): void + { + $conditions = new ExpiredShortUrlsConditions(); + $this->expiredShortUrlsRepository->expects($this->once())->method('dryCount')->with($conditions)->willReturn(2); + + $result = $this->createService()->countExpiredShortUrls($conditions); + + self::assertEquals(2, $result); + } + private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService { return new DeleteShortUrlService($this->em, new DeleteShortUrlsOptions( $visitsThreshold, $checkVisitsThreshold, - ), $this->urlResolver); + ), $this->urlResolver, $this->expiredShortUrlsRepository); } } From 527d28ad81b78b0d812f0e88b146705208dbe9cc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Apr 2024 19:18:56 +0200 Subject: [PATCH 45/64] Add DeleteExpiredShortUrlsCommand test --- CHANGELOG.md | 5 ++ .../DeleteExpiredShortUrlsCommandTest.php | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d4e3ac..4dc88849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Previously, this was exposed only for orphan visits, since this can be an arbitrary value for those. * [#2077](https://github.com/shlinkio/shlink/issues/2077) When sending visits to Matomo, the short URL title is now used as document title in matomo. +* [#2059](https://github.com/shlinkio/shlink/issues/2059) Add new `short-url:delete-expired` command that can be used to programmatically delete expired short URLs. + + Expired short URLs are those that have a `calidUntil` date in the past, or optionally, that have reached the max amount of visits. + + This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space. ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. diff --git a/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php new file mode 100644 index 00000000..5930a93c --- /dev/null +++ b/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php @@ -0,0 +1,90 @@ +service = $this->createMock(DeleteShortUrlServiceInterface::class); + $this->commandTester = CliTestUtils::testerForCommand(new DeleteExpiredShortUrlsCommand($this->service)); + } + + #[Test] + public function warningIsDisplayedAndExecutionCanBeCancelled(): void + { + $this->service->expects($this->never())->method('countExpiredShortUrls'); + $this->service->expects($this->never())->method('deleteExpiredShortUrls'); + + $this->commandTester->setInputs(['n']); + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + $status = $this->commandTester->getStatusCode(); + + self::assertStringContainsString('Careful!', $output); + self::assertEquals(ExitCode::EXIT_WARNING, $status); + } + + #[Test] + #[TestWith([[], true])] + #[TestWith([['--force' => true], false])] + #[TestWith([['-f' => true], false])] + public function deletionIsExecutedByDefault(array $input, bool $expectsWarning): void + { + $this->service->expects($this->never())->method('countExpiredShortUrls'); + $this->service->expects($this->once())->method('deleteExpiredShortUrls')->willReturn(5); + + $this->commandTester->setInputs(['y']); + $this->commandTester->execute($input); + $output = $this->commandTester->getDisplay(); + $status = $this->commandTester->getStatusCode(); + + if ($expectsWarning) { + self::assertStringContainsString('Careful!', $output); + } else { + self::assertStringNotContainsString('Careful!', $output); + } + self::assertStringContainsString('5 expired short URLs have been deleted', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $status); + } + + #[Test] + public function countIsExecutedDuringDryRun(): void + { + $this->service->expects($this->once())->method('countExpiredShortUrls')->willReturn(38); + $this->service->expects($this->never())->method('deleteExpiredShortUrls'); + + $this->commandTester->execute(['--dry-run' => true]); + $output = $this->commandTester->getDisplay(); + $status = $this->commandTester->getStatusCode(); + + self::assertStringNotContainsString('Careful!', $output); + self::assertStringContainsString('There are 38 expired short URLs matching provided conditions', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $status); + } + + #[Test] + #[TestWith([[], new ExpiredShortUrlsConditions()])] + #[TestWith([['--evaluate-max-visits' => true], new ExpiredShortUrlsConditions(maxVisitsReached: true)])] + public function providesExpectedConditionsToService(array $extraInput, ExpiredShortUrlsConditions $conditions): void + { + $this->service->expects($this->once())->method('countExpiredShortUrls')->with($conditions)->willReturn(4); + $this->commandTester->execute(['--dry-run' => true, ...$extraInput]); + } +} From 38819965602d52d399677687fa1c9a901ecc0198 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Apr 2024 19:20:38 +0200 Subject: [PATCH 46/64] Migrate from docker-compose to docker compose in CI pipelines --- .github/workflows/ci-db-tests.yml | 4 ++-- .github/workflows/ci-tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index dd797e83..8cea11f7 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -23,7 +23,7 @@ jobs: run: sudo ./data/infra/ci/install-ms-odbc.sh - name: Start database server if: ${{ inputs.platform != 'sqlite:ci' }} - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ inputs.platform }} + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ inputs.platform }} - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} @@ -31,7 +31,7 @@ jobs: extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} - run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" + run: docker compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - name: Run tests run: composer test:db:${{ inputs.platform }} - name: Upload code coverage diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index ea26ccd7..d2cf4d9a 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -20,10 +20,10 @@ jobs: - uses: actions/checkout@v4 - name: Start postgres database server if: ${{ inputs.test-group == 'api' }} - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - name: Start maria database server if: ${{ inputs.test-group == 'cli' }} - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} From b7db676cba848a282c7f004f0b217b2a851f8a65 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Apr 2024 19:21:39 +0200 Subject: [PATCH 47/64] Test non-interactivity on DeleteExpiredShortUrlsCommand --- CHANGELOG.md | 2 +- .../ShortUrl/DeleteExpiredShortUrlsCommandTest.php | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc88849..a397f3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2077](https://github.com/shlinkio/shlink/issues/2077) When sending visits to Matomo, the short URL title is now used as document title in matomo. * [#2059](https://github.com/shlinkio/shlink/issues/2059) Add new `short-url:delete-expired` command that can be used to programmatically delete expired short URLs. - Expired short URLs are those that have a `calidUntil` date in the past, or optionally, that have reached the max amount of visits. + Expired short URLs are those that have a `validUntil` date in the past, or optionally, that have reached the max amount of visits. This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space. diff --git a/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php index 5930a93c..ea580064 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteExpiredShortUrlsCommandTest.php @@ -42,16 +42,17 @@ class DeleteExpiredShortUrlsCommandTest extends TestCase } #[Test] - #[TestWith([[], true])] - #[TestWith([['--force' => true], false])] - #[TestWith([['-f' => true], false])] - public function deletionIsExecutedByDefault(array $input, bool $expectsWarning): void + #[TestWith([[], [], true])] + #[TestWith([['--force' => true], [], false])] + #[TestWith([['-f' => true], [], false])] + #[TestWith([[], ['interactive' => false], false])] + public function deletionIsExecutedByDefault(array $input, array $options, bool $expectsWarning): void { $this->service->expects($this->never())->method('countExpiredShortUrls'); $this->service->expects($this->once())->method('deleteExpiredShortUrls')->willReturn(5); $this->commandTester->setInputs(['y']); - $this->commandTester->execute($input); + $this->commandTester->execute($input, $options); $output = $this->commandTester->getDisplay(); $status = $this->commandTester->getStatusCode(); From e1cf0c4ea740a473a27daa32278132d4595512ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Apr 2024 11:26:17 +0200 Subject: [PATCH 48/64] Forward request ID from sync request process to async job processes --- CHANGELOG.md | 4 ++ bin/roadrunner-worker.php | 6 ++- composer.json | 4 +- config/autoload/logger.global.php | 9 ++++ .../Helper/RequestIdProvider.php | 20 +++++++++ .../Helper/RequestIdProviderTest.php | 44 +++++++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 module/Core/src/EventDispatcher/Helper/RequestIdProvider.php create mode 100644 module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a397f3af..9d1315d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This has been achieved by introducing a new table which tracks slotted visits counts. We can then `SUM` all counts for certain short URL, avoiding `COUNT(visits)` aggregates which are much less performant when there are a lot of visits. +* [#2049](https://github.com/shlinkio/shlink/issues/2049) Request ID is now propagated to the background tasks/jobs scheduled during a request. + + This allows for a better traceability, as the logs generated during those jobs will have a matching UUID as the logs generated during the request the triggered the job. + ### Deprecated * *Nothing* diff --git a/bin/roadrunner-worker.php b/bin/roadrunner-worker.php index c4a89a85..8466c683 100644 --- a/bin/roadrunner-worker.php +++ b/bin/roadrunner-worker.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Mezzio\Application; use Psr\Container\ContainerInterface; +use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener; use Spiral\RoadRunner\Http\PSR7Worker; @@ -27,6 +28,9 @@ use function Shlinkio\Shlink\Config\env; } } } else { - $container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks(); + $requestIdMiddleware = $container->get(RequestIdMiddleware::class); + $container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks( + fn (string $requestId) => $requestIdMiddleware->setCurrentRequestId($requestId), + ); } })(); diff --git a/composer.json b/composer.json index 8c32b1c9..1b8832ed 100644 --- a/composer.json +++ b/composer.json @@ -43,9 +43,9 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "^6.0", + "shlinkio/shlink-common": "dev-main#3cb0845 as 6.1", "shlinkio/shlink-config": "^3.0", - "shlinkio/shlink-event-dispatcher": "^4.0", + "shlinkio/shlink-event-dispatcher": "dev-main#a2a5d6f as 4.1", "shlinkio/shlink-importer": "^5.3.1", "shlinkio/shlink-installer": "^9.0", "shlinkio/shlink-ip-geolocation": "^4.0", diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 67b737ae..c7d7d757 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink; +use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Monolog\Level; use Monolog\Logger; @@ -13,6 +14,8 @@ use Shlinkio\Shlink\Common\Logger\LoggerFactory; use Shlinkio\Shlink\Common\Logger\LoggerType; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; +use Shlinkio\Shlink\Core\EventDispatcher\Helper\RequestIdProvider; +use Shlinkio\Shlink\EventDispatcher\Util\RequestIdProviderInterface; use function Shlinkio\Shlink\Config\runningInRoadRunner; @@ -44,14 +47,20 @@ return (static function (): array { 'Logger_Shlink' => [LoggerFactory::class, 'Shlink'], 'Logger_Access' => [LoggerFactory::class, 'Access'], NullLogger::class => InvokableFactory::class, + RequestIdProvider::class => ConfigAbstractFactory::class, ], 'aliases' => [ 'logger' => 'Logger_Shlink', Logger::class => 'Logger_Shlink', LoggerInterface::class => 'Logger_Shlink', AccessLogMiddleware::LOGGER_SERVICE_NAME => 'Logger_Access', + RequestIdProviderInterface::class => RequestIdProvider::class, ], ], + ConfigAbstractFactory::class => [ + RequestIdProvider::class => [RequestIdMiddleware::class], + ], + ]; })(); diff --git a/module/Core/src/EventDispatcher/Helper/RequestIdProvider.php b/module/Core/src/EventDispatcher/Helper/RequestIdProvider.php new file mode 100644 index 00000000..2ff529ee --- /dev/null +++ b/module/Core/src/EventDispatcher/Helper/RequestIdProvider.php @@ -0,0 +1,20 @@ +requestIdMiddleware->currentRequestId(); + } +} diff --git a/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php b/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php new file mode 100644 index 00000000..eb66f49e --- /dev/null +++ b/module/Core/test/EventDispatcher/Helper/RequestIdProviderTest.php @@ -0,0 +1,44 @@ +middleware = new RequestIdMiddleware(); + $this->provider = new RequestIdProvider($this->middleware); + } + + #[Test] + public function requestIdTrackedByMiddlewareIsForwarded(): void + { + $initialId = $this->middleware->currentRequestId(); + self::assertEquals($initialId, $this->provider->currentRequestId()); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle')->willReturn(new Response()); + $this->middleware->process(ServerRequestFactory::fromGlobals(), $handler); + $idAfterProcessingRequest = $this->middleware->currentRequestId(); + self::assertNotEquals($idAfterProcessingRequest, $initialId); + self::assertEquals($idAfterProcessingRequest, $this->provider->currentRequestId()); + + $manuallySetId = 'foobar'; + $this->middleware->setCurrentRequestId($manuallySetId); + self::assertNotEquals($manuallySetId, $idAfterProcessingRequest); + self::assertEquals($manuallySetId, $this->provider->currentRequestId()); + } +} From 8a273e01e9f07aaf22e1f00457c3c6edd630a2f3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Apr 2024 08:47:01 +0200 Subject: [PATCH 49/64] Allow memory_limit to be configurable --- CHANGELOG.md | 2 ++ config/container.php | 2 ++ data/infra/php.ini | 1 - docker/config/php.ini | 1 - module/Core/src/Config/EnvVars.php | 1 + 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1315d1..716f8787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This allows for a better traceability, as the logs generated during those jobs will have a matching UUID as the logs generated during the request the triggered the job. +* [#2087](https://github.com/shlinkio/shlink/issues/2087) Allow `memory_limit` to be configured via the new `MEMORY_LIMIT` env var. + ### Deprecated * *Nothing* diff --git a/config/container.php b/config/container.php index 5d263173..bfab7763 100644 --- a/config/container.php +++ b/config/container.php @@ -12,6 +12,8 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; +// Set a default memory limit, but allow custom values +ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv('512M')); // This is one of the first files loaded. Configure the timezone here date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get())); diff --git a/data/infra/php.ini b/data/infra/php.ini index 64838d11..46ad43bb 100644 --- a/data/infra/php.ini +++ b/data/infra/php.ini @@ -1,6 +1,5 @@ display_errors=On error_reporting=-1 -memory_limit=-1 log_errors_max_len=0 zend.assertions=1 assert.exception=1 diff --git a/docker/config/php.ini b/docker/config/php.ini index 248e508d..f6c718d0 100644 --- a/docker/config/php.ini +++ b/docker/config/php.ini @@ -1,4 +1,3 @@ log_errors_max_len=0 zend.assertions=1 assert.exception=1 -memory_limit=512M diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index e5df9532..bae68e84 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -71,6 +71,7 @@ enum EnvVars: string case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; case TIMEZONE = 'TIMEZONE'; case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; + case MEMORY_LIMIT = 'MEMORY_LIMIT'; public function loadFromEnv(mixed $default = null): mixed { From 5e74dd7a6d534f54ae82e77510a99b94054b1e11 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Apr 2024 09:39:38 +0200 Subject: [PATCH 50/64] Update to installer version with support for memory limit option --- CHANGELOG.md | 2 +- composer.json | 2 +- config/autoload/installer.global.php | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 716f8787..e9e7969e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This allows for a better traceability, as the logs generated during those jobs will have a matching UUID as the logs generated during the request the triggered the job. -* [#2087](https://github.com/shlinkio/shlink/issues/2087) Allow `memory_limit` to be configured via the new `MEMORY_LIMIT` env var. +* [#2087](https://github.com/shlinkio/shlink/issues/2087) Allow `memory_limit` to be configured via the new `MEMORY_LIMIT` env var or configuration option. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 1b8832ed..f051f2ca 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "shlinkio/shlink-config": "^3.0", "shlinkio/shlink-event-dispatcher": "dev-main#a2a5d6f as 4.1", "shlinkio/shlink-importer": "^5.3.1", - "shlinkio/shlink-installer": "^9.0", + "shlinkio/shlink-installer": "dev-develop#164e23d as 9.1", "shlinkio/shlink-ip-geolocation": "^4.0", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index b6a79679..4ebd6716 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -12,6 +12,7 @@ return [ 'installer' => [ 'enabled_options' => [ Option\Server\RuntimeConfigOption::class, + Option\Server\MemoryLimitConfigOption::class, Option\Database\DatabaseDriverConfigOption::class, Option\Database\DatabaseNameConfigOption::class, Option\Database\DatabaseHostConfigOption::class, From 850dde1a06e3298db37935ee366df79330812d45 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Apr 2024 22:28:13 +0200 Subject: [PATCH 51/64] Fix custom slugs not being properly imported from bitly --- CHANGELOG.md | 1 + composer.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e7969e..b46c26ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed +* [#2095](https://github.com/shlinkio/shlink/issues/2095) Fix custom slugs not being properly imported from bitly * Fix error when importing short URLs and visits from a Shlink 4.x instance diff --git a/composer.json b/composer.json index f051f2ca..99dc8a88 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "shlinkio/shlink-common": "dev-main#3cb0845 as 6.1", "shlinkio/shlink-config": "^3.0", "shlinkio/shlink-event-dispatcher": "dev-main#a2a5d6f as 4.1", - "shlinkio/shlink-importer": "^5.3.1", + "shlinkio/shlink-importer": "^5.3.2", "shlinkio/shlink-installer": "dev-develop#164e23d as 9.1", "shlinkio/shlink-ip-geolocation": "^4.0", "shlinkio/shlink-json": "^1.1", From c57494d7cd7fba26d68d6975d8421cee96ccc0aa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Apr 2024 09:13:48 +0200 Subject: [PATCH 52/64] Extract logic to send visits to Matomo to its own service --- module/Core/config/dependencies.config.php | 3 + .../Core/config/event_dispatcher.config.php | 4 +- .../ShortUrlMethodsProcessor.php | 2 +- .../Matomo/SendVisitToMatomo.php | 46 +------ .../Core/src/Matomo/MatomoTrackerBuilder.php | 4 +- module/Core/src/Matomo/MatomoVisitSender.php | 60 +++++++++ .../src/Matomo/MatomoVisitSenderInterface.php | 12 ++ module/Core/src/Util/RedirectStatus.php | 2 +- .../Matomo/SendVisitToMatomoTest.php | 109 +++------------- .../test/Matomo/MatomoVisitSenderTest.php | 119 ++++++++++++++++++ 10 files changed, 216 insertions(+), 145 deletions(-) create mode 100644 module/Core/src/Matomo/MatomoVisitSender.php create mode 100644 module/Core/src/Matomo/MatomoVisitSenderInterface.php create mode 100644 module/Core/test/Matomo/MatomoVisitSenderTest.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 5f3d8fae..efdd7d33 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -10,6 +10,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Lock; @@ -101,6 +102,7 @@ return [ Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'], Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class, + Matomo\MatomoVisitSender::class => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -110,6 +112,7 @@ return [ ConfigAbstractFactory::class => [ Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class], + Matomo\MatomoVisitSender::class => [Matomo\MatomoTrackerBuilder::class, ShortUrlStringifier::class], ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 8fc534d4..f401f255 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -12,7 +12,6 @@ use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; @@ -157,9 +156,8 @@ return (static function (): array { EventDispatcher\Matomo\SendVisitToMatomo::class => [ 'em', 'Logger_Shlink', - ShortUrlStringifier::class, Matomo\MatomoOptions::class, - Matomo\MatomoTrackerBuilder::class, + Matomo\MatomoVisitSender::class, ], EventDispatcher\UpdateGeoLiteDb::class => [ diff --git a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php index 42f00889..23ffe326 100644 --- a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php +++ b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php @@ -40,7 +40,7 @@ class ShortUrlMethodsProcessor $redirectStatus = RedirectStatus::tryFrom( $config['redirects']['redirect_status_code'] ?? 0, ) ?? DEFAULT_REDIRECT_STATUS_CODE; - $redirectRoute['allowed_methods'] = $redirectStatus->isLegacyStatus() + $redirectRoute['allowed_methods'] = $redirectStatus->isGetOnlyStatus() ? [RequestMethodInterface::METHOD_GET] : Route::HTTP_METHOD_ANY; diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index d0fa1035..c47b9cba 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -8,8 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; -use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Throwable; @@ -18,9 +17,8 @@ readonly class SendVisitToMatomo public function __construct( private EntityManagerInterface $em, private LoggerInterface $logger, - private ShortUrlStringifier $shortUrlStringifier, private MatomoOptions $matomoOptions, - private MatomoTrackerBuilderInterface $trackerBuilder, + private MatomoVisitSenderInterface $visitSender, ) { } @@ -42,48 +40,10 @@ readonly class SendVisitToMatomo } try { - $tracker = $this->trackerBuilder->buildMatomoTracker(); - - $tracker - ->setUrl($this->resolveUrlToTrack($visit)) - ->setCustomTrackingParameter('type', $visit->type->value) - ->setUserAgent($visit->userAgent) - ->setUrlReferrer($visit->referer); - - $location = $visit->getVisitLocation(); - if ($location !== null) { - $tracker - ->setCity($location->cityName) - ->setCountry($location->countryName) - ->setLatitude($location->latitude) - ->setLongitude($location->longitude); - } - - // Set not obfuscated IP if possible, as matomo handles obfuscation itself - $ip = $visitLocated->originalIpAddress ?? $visit->remoteAddr; - if ($ip !== null) { - $tracker->setIp($ip); - } - - if ($visit->isOrphan()) { - $tracker->setCustomTrackingParameter('orphan', 'true'); - } - - // Send the short URL title or an empty document title to avoid different actions to be created by matomo - $tracker->doTrackPageView($visit->shortUrl?->title() ?? ''); + $this->visitSender->sendVisitToMatomo($visit, $visitLocated->originalIpAddress); } catch (Throwable $e) { // Capture all exceptions to make sure this does not interfere with the regular execution $this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]); } } - - public function resolveUrlToTrack(Visit $visit): string - { - $shortUrl = $visit->shortUrl; - if ($shortUrl === null) { - return $visit->visitedUrl ?? ''; - } - - return $this->shortUrlStringifier->stringify($shortUrl); - } } diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php index 4bad6799..e006271b 100644 --- a/module/Core/src/Matomo/MatomoTrackerBuilder.php +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -7,11 +7,11 @@ namespace Shlinkio\Shlink\Core\Matomo; use MatomoTracker; use Shlinkio\Shlink\Core\Exception\RuntimeException; -class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface +readonly class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface { public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds - public function __construct(private readonly MatomoOptions $options) + public function __construct(private MatomoOptions $options) { } diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php new file mode 100644 index 00000000..8caed3cc --- /dev/null +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -0,0 +1,60 @@ +trackerBuilder->buildMatomoTracker(); + + $tracker + ->setUrl($this->resolveUrlToTrack($visit)) + ->setCustomTrackingParameter('type', $visit->type->value) + ->setUserAgent($visit->userAgent) + ->setUrlReferrer($visit->referer); + + $location = $visit->getVisitLocation(); + if ($location !== null) { + $tracker + ->setCity($location->cityName) + ->setCountry($location->countryName) + ->setLatitude($location->latitude) + ->setLongitude($location->longitude); + } + + // Set not obfuscated IP if possible, as matomo handles obfuscation itself + $ip = $originalIpAddress ?? $visit->remoteAddr; + if ($ip !== null) { + $tracker->setIp($ip); + } + + if ($visit->isOrphan()) { + $tracker->setCustomTrackingParameter('orphan', 'true'); + } + + // Send the short URL title or an empty document title to avoid different actions to be created by matomo + $tracker->doTrackPageView($visit->shortUrl?->title() ?? ''); + } + + private function resolveUrlToTrack(Visit $visit): string + { + $shortUrl = $visit->shortUrl; + if ($shortUrl === null) { + return $visit->visitedUrl ?? ''; + } + + return $this->shortUrlStringifier->stringify($shortUrl); + } +} diff --git a/module/Core/src/Matomo/MatomoVisitSenderInterface.php b/module/Core/src/Matomo/MatomoVisitSenderInterface.php new file mode 100644 index 00000000..fef16367 --- /dev/null +++ b/module/Core/src/Matomo/MatomoVisitSenderInterface.php @@ -0,0 +1,12 @@ +em = $this->createMock(EntityManagerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + $this->visitSender = $this->createMock(MatomoVisitSenderInterface::class); } #[Test] public function visitIsNotSentWhenMatomoIsDisabled(): void { $this->em->expects($this->never())->method('find'); - $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->visitSender->expects($this->never())->method('sendVisitToMatomo'); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); @@ -53,7 +46,7 @@ class SendVisitToMatomoTest extends TestCase public function visitIsNotSentWhenItDoesNotExist(): void { $this->em->expects($this->once())->method('find')->willReturn(null); - $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->visitSender->expects($this->never())->method('sendVisitToMatomo'); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->once())->method('warning')->with( 'Tried to send visit with id "{visitId}" to matomo, but it does not exist.', @@ -63,97 +56,24 @@ class SendVisitToMatomoTest extends TestCase ($this->listener())(new VisitLocated('123')); } - #[Test, DataProvider('provideTrackerMethods')] - public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + #[Test, DataProvider('provideOriginalIpAddress')] + public function visitIsSentWhenItExists(?string $originalIpAddress): void { $visitId = '123'; - - $tracker = $this->createMock(MatomoTracker::class); - $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); - $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); - $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); - $tracker->expects($this->once())->method('doTrackPageView')->with($visit->shortUrl?->title() ?? ''); - - if ($visit->isOrphan()) { - $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ - ['type', $visit->type->value, $tracker], - ['orphan', 'true', $tracker], - ]); - } else { - $tracker->expects($this->once())->method('setCustomTrackingParameter')->with( - 'type', - $visit->type->value, - )->willReturn($tracker); - } - - foreach ($invokedMethods as $invokedMethod) { - $tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker); - } + $visit = Visit::forBasePath(Visitor::emptyInstance()); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); - $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->visitSender->expects($this->once())->method('sendVisitToMatomo')->with($visit, $originalIpAddress); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); ($this->listener())(new VisitLocated($visitId, $originalIpAddress)); } - public static function provideTrackerMethods(): iterable + public static function provideOriginalIpAddress(): iterable { - yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; - yield 'located regular visit' => [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) - ->locate(VisitLocation::fromGeolocation(new Location( - countryCode: 'countryCode', - countryName: 'countryName', - regionName: 'regionName', - city: 'city', - latitude: 123, - longitude: 123, - timeZone: 'timeZone', - ))), - '1.2.3.4', - ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], - ]; - yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; - } - - #[Test, DataProvider('provideUrlsToTrack')] - public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void - { - $visitId = '123'; - - $tracker = $this->createMock(MatomoTracker::class); - $tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker); - $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); - $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); - $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); - $tracker->expects($this->once())->method('doTrackPageView'); - - $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); - $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); - $this->logger->expects($this->never())->method('error'); - $this->logger->expects($this->never())->method('warning'); - - ($this->listener())(new VisitLocated($visitId)); - } - - public static function provideUrlsToTrack(): iterable - { - yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; - yield 'orphan visit with visited URL' => [ - Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), - 'https://s.test/foo', - ]; - yield 'non-orphan visit' => [ - Visit::forValidShortUrl(ShortUrl::create( - ShortUrlCreation::fromRawData([ - ShortUrlInputFilter::LONG_URL => 'https://shlink.io', - ShortUrlInputFilter::CUSTOM_SLUG => 'bar', - ]), - ), Visitor::emptyInstance()), - 'http://s2.test/bar', - ]; + yield 'no original IP address' => [null]; + yield 'original IP address' => ['1.2.3.4']; } #[Test] @@ -165,7 +85,7 @@ class SendVisitToMatomoTest extends TestCase $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( $this->createMock(Visit::class), ); - $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willThrowException($e); + $this->visitSender->expects($this->once())->method('sendVisitToMatomo')->willThrowException($e); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->once())->method('error')->with( 'An error occurred while trying to send visit to Matomo. {e}', @@ -180,9 +100,8 @@ class SendVisitToMatomoTest extends TestCase return new SendVisitToMatomo( $this->em, $this->logger, - new ShortUrlStringifier(['hostname' => 's2.test']), new MatomoOptions(enabled: $enabled), - $this->trackerBuilder, + $this->visitSender, ); } } diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php new file mode 100644 index 00000000..3e08d6aa --- /dev/null +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -0,0 +1,119 @@ +trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + $this->visitSender = new MatomoVisitSender( + $this->trackerBuilder, + new ShortUrlStringifier(['hostname' => 's2.test']), + ); + } + + #[Test, DataProvider('provideTrackerMethods')] + public function visitIsSentToMatomo(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + { + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView')->with($visit->shortUrl?->title() ?? ''); + + if ($visit->isOrphan()) { + $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ + ['type', $visit->type->value, $tracker], + ['orphan', 'true', $tracker], + ]); + } else { + $tracker->expects($this->once())->method('setCustomTrackingParameter')->with( + 'type', + $visit->type->value, + )->willReturn($tracker); + } + + foreach ($invokedMethods as $invokedMethod) { + $tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker); + } + + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + + $this->visitSender->sendVisitToMatomo($visit, $originalIpAddress); + } + + public static function provideTrackerMethods(): iterable + { + yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; + yield 'located regular visit' => [ + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) + ->locate(VisitLocation::fromGeolocation(new Location( + countryCode: 'countryCode', + countryName: 'countryName', + regionName: 'regionName', + city: 'city', + latitude: 123, + longitude: 123, + timeZone: 'timeZone', + ))), + '1.2.3.4', + ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], + ]; + yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; + } + + #[Test, DataProvider('provideUrlsToTrack')] + public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void + { + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); + $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView'); + + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + + $this->visitSender->sendVisitToMatomo($visit); + } + + public static function provideUrlsToTrack(): iterable + { + yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; + yield 'orphan visit with visited URL' => [ + Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), + 'https://s.test/foo', + ]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl(ShortUrl::create( + ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://shlink.io', + ShortUrlInputFilter::CUSTOM_SLUG => 'bar', + ]), + ), Visitor::emptyInstance()), + 'http://s2.test/bar', + ]; + } +} From 13ee71f3519ce68f1fcaadc67a0e4a1b3cd6b3af Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Apr 2024 09:24:05 +0200 Subject: [PATCH 53/64] Move allowed HTTP methods definition to RedirectStatus enum --- .../PostProcessor/ShortUrlMethodsProcessor.php | 6 +----- module/Core/src/Util/RedirectStatus.php | 12 ++++++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php index 23ffe326..a73c584d 100644 --- a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php +++ b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config\PostProcessor; -use Fig\Http\Message\RequestMethodInterface; -use Mezzio\Router\Route; use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Util\RedirectStatus; @@ -40,9 +38,7 @@ class ShortUrlMethodsProcessor $redirectStatus = RedirectStatus::tryFrom( $config['redirects']['redirect_status_code'] ?? 0, ) ?? DEFAULT_REDIRECT_STATUS_CODE; - $redirectRoute['allowed_methods'] = $redirectStatus->isGetOnlyStatus() - ? [RequestMethodInterface::METHOD_GET] - : Route::HTTP_METHOD_ANY; + $redirectRoute['allowed_methods'] = $redirectStatus->allowedHttpMethods(); $config['routes'] = [...$rest, $redirectRoute]; return $config; diff --git a/module/Core/src/Util/RedirectStatus.php b/module/Core/src/Util/RedirectStatus.php index 347376d2..defea11b 100644 --- a/module/Core/src/Util/RedirectStatus.php +++ b/module/Core/src/Util/RedirectStatus.php @@ -2,6 +2,9 @@ namespace Shlinkio\Shlink\Core\Util; +use Fig\Http\Message\RequestMethodInterface; +use Mezzio\Router\Route; + use function Shlinkio\Shlink\Core\ArrayUtils\contains; enum RedirectStatus: int @@ -16,8 +19,13 @@ enum RedirectStatus: int return contains($this, [self::STATUS_301, self::STATUS_308]); } - public function isGetOnlyStatus(): bool + /** + * @return array|Route::HTTP_METHOD_ANY + */ + public function allowedHttpMethods(): array|null { - return contains($this, [self::STATUS_301, self::STATUS_302]); + return contains($this, [self::STATUS_301, self::STATUS_302]) + ? [RequestMethodInterface::METHOD_GET] + : Route::HTTP_METHOD_ANY; } } From ce0f61b66d67a2e1e202d6c592f3f72e69d04644 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Apr 2024 18:29:55 +0200 Subject: [PATCH 54/64] Allow filtering by date in VisitIterationRepository --- module/Core/config/dependencies.config.php | 4 +-- .../src/Visit/Geolocation/VisitLocator.php | 4 +-- ...itory.php => VisitIterationRepository.php} | 18 ++++++++-- ... => VisitIterationRepositoryInterface.php} | 5 +-- ...t.php => VisitIterationRepositoryTest.php} | 33 ++++++++++++++++--- .../Visit/Geolocation/VisitLocatorTest.php | 6 ++-- 6 files changed, 54 insertions(+), 16 deletions(-) rename module/Core/src/Visit/Repository/{VisitLocationRepository.php => VisitIterationRepository.php} (72%) rename module/Core/src/Visit/Repository/{VisitLocationRepositoryInterface.php => VisitIterationRepositoryInterface.php} (71%) rename module/Core/test-db/Visit/Repository/{VisitLocationRepositoryTest.php => VisitIterationRepositoryTest.php} (56%) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index efdd7d33..8a333824 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -73,7 +73,7 @@ return [ Visit\Geolocation\VisitLocator::class => ConfigAbstractFactory::class, Visit\Geolocation\VisitToLocationHelper::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, - Visit\Repository\VisitLocationRepository::class => [ + Visit\Repository\VisitIterationRepository::class => [ EntityRepositoryFactory::class, Visit\Entity\Visit::class, ], @@ -146,7 +146,7 @@ return [ ShortUrl\Repository\ShortUrlListRepository::class, Options\UrlShortenerOptions::class, ], - Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitLocationRepository::class], + Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class], Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class], Visit\VisitsStatsHelper::class => ['em'], Tag\TagService::class => ['em'], diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index 4b3b8e22..63cb6137 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -8,14 +8,14 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; -use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocator implements VisitLocatorInterface { public function __construct( private readonly EntityManagerInterface $em, - private readonly VisitLocationRepositoryInterface $repo, + private readonly VisitIterationRepositoryInterface $repo, ) { } diff --git a/module/Core/src/Visit/Repository/VisitLocationRepository.php b/module/Core/src/Visit/Repository/VisitIterationRepository.php similarity index 72% rename from module/Core/src/Visit/Repository/VisitLocationRepository.php rename to module/Core/src/Visit/Repository/VisitIterationRepository.php index 6db1a4f8..0431788c 100644 --- a/module/Core/src/Visit/Repository/VisitLocationRepository.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepository.php @@ -6,9 +6,14 @@ namespace Shlinkio\Shlink\Core\Visit\Repository; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; +use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -class VisitLocationRepository extends EntitySpecificationRepository implements VisitLocationRepositoryInterface +/** + * Allows iterating large amounts of visits in a memory-efficient way, to use in batch processes + */ +class VisitIterationRepository extends EntitySpecificationRepository implements VisitIterationRepositoryInterface { /** * @return iterable @@ -42,9 +47,18 @@ class VisitLocationRepository extends EntitySpecificationRepository implements V /** * @return iterable */ - public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable { $qb = $this->createQueryBuilder('v'); + if ($dateRange?->startDate !== null) { + $qb->andWhere($qb->expr()->gte('v.date', ':since')); + $qb->setParameter('since', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); + } + if ($dateRange?->endDate !== null) { + $qb->andWhere($qb->expr()->lte('v.date', ':until')); + $qb->setParameter('until', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); + } + return $this->visitsIterableForQuery($qb, $blockSize); } diff --git a/module/Core/src/Visit/Repository/VisitLocationRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php similarity index 71% rename from module/Core/src/Visit/Repository/VisitLocationRepositoryInterface.php rename to module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php index 083d61f2..d4ffb864 100644 --- a/module/Core/src/Visit/Repository/VisitLocationRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Repository; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -interface VisitLocationRepositoryInterface +interface VisitIterationRepositoryInterface { public const DEFAULT_BLOCK_SIZE = 10000; @@ -23,5 +24,5 @@ interface VisitLocationRepositoryInterface /** * @return iterable */ - public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; } diff --git a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php similarity index 56% rename from module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php rename to module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php index c5aadf1f..7a683e3c 100644 --- a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -4,27 +4,29 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Core\Visit\Repository; +use Cake\Chronos\Chronos; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepository; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepository; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function array_map; use function range; -class VisitLocationRepositoryTest extends DatabaseTestCase +class VisitIterationRepositoryTest extends DatabaseTestCase { - private VisitLocationRepository $repo; + private VisitIterationRepository $repo; protected function setUp(): void { $em = $this->getEntityManager(); - $this->repo = new VisitLocationRepository($em, $em->getClassMetadata(Visit::class)); + $this->repo = new VisitIterationRepository($em, $em->getClassMetadata(Visit::class)); } #[Test, DataProvider('provideBlockSize')] @@ -33,7 +35,9 @@ class VisitLocationRepositoryTest extends DatabaseTestCase $shortUrl = ShortUrl::createFake(); $this->getEntityManager()->persist($shortUrl); + $unmodifiedDate = Chronos::now(); for ($i = 0; $i < 6; $i++) { + Chronos::setTestNow($unmodifiedDate->subDays($i)); // Enforce a different day for every visit $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); if ($i >= 2) { @@ -44,15 +48,34 @@ class VisitLocationRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($visit); } + Chronos::setTestNow(); $this->getEntityManager()->flush(); $withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize); $unlocated = $this->repo->findUnlocatedVisits($blockSize); - $all = $this->repo->findAllVisits($blockSize); + $all = $this->repo->findAllVisits(blockSize: $blockSize); + $lastThreeDays = $this->repo->findAllVisits( + dateRange: DateRange::since(Chronos::now()->subDays(2)), + blockSize: $blockSize, + ); + $firstTwoDays = $this->repo->findAllVisits( + dateRange: DateRange::until(Chronos::now()->subDays(4)), + blockSize: $blockSize, + ); + $daysInBetween = $this->repo->findAllVisits( + dateRange: DateRange::between( + startDate: Chronos::now()->subDays(5), + endDate: Chronos::now()->subDays(2), + ), + blockSize: $blockSize, + ); self::assertCount(2, [...$unlocated]); self::assertCount(4, [...$withEmptyLocation]); self::assertCount(6, [...$all]); + self::assertCount(3, [...$lastThreeDays]); + self::assertCount(2, [...$firstTwoDays]); + self::assertCount(4, [...$daysInBetween]); } public static function provideBlockSize(): iterable diff --git a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php index 1d3af228..f1d86f63 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitGeolocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use function array_map; @@ -30,12 +30,12 @@ class VisitLocatorTest extends TestCase { private VisitLocator $visitService; private MockObject & EntityManager $em; - private MockObject & VisitLocationRepositoryInterface $repo; + private MockObject & VisitIterationRepositoryInterface $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); - $this->repo = $this->createMock(VisitLocationRepositoryInterface::class); + $this->repo = $this->createMock(VisitIterationRepositoryInterface::class); $this->visitService = new VisitLocator($this->em, $this->repo); } From ca42425b33741d186427d6a65029fe5189bdc2f1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Apr 2024 18:42:59 +0200 Subject: [PATCH 55/64] Make Visit::date field readonly --- .../Visit/AbstractVisitsListCommand.php | 2 +- .../Domain/GetDomainVisitsCommandTest.php | 2 +- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 2 +- .../Command/Tag/GetTagVisitsCommandTest.php | 2 +- .../Visit/GetNonOrphanVisitsCommandTest.php | 2 +- .../Visit/GetOrphanVisitsCommandTest.php | 2 +- .../src/Importer/ImportedLinksProcessor.php | 2 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 2 +- module/Core/src/Visit/Entity/Visit.php | 11 +------- .../Visit/Repository/VisitRepositoryTest.php | 27 ++++++++++--------- .../PublishingUpdatesGeneratorTest.php | 4 +-- module/Core/test/Visit/Entity/VisitTest.php | 8 +++--- .../Rest/test-api/Fixtures/VisitsFixture.php | 21 ++++++++------- 13 files changed, 41 insertions(+), 46 deletions(-) diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index a3e8a43c..d3a49c53 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -55,7 +55,7 @@ abstract class AbstractVisitsListCommand extends Command $rowData = [ 'referer' => $visit->referer, - 'date' => $visit->getDate()->toAtomString(), + 'date' => $visit->date->toAtomString(), 'userAgent' => $visit->userAgent, 'potentialBot' => $visit->potentialBot, 'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown', diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index 7f4bd076..6563abc0 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -60,7 +60,7 @@ class GetDomainVisitsCommandTest extends TestCase +---------+---------------------------+------------+---------+--------+---------------+ | Referer | Date | User agent | Country | City | Short Url | +---------+---------------------------+------------+---------+--------+---------------+ - | foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | +---------+---------------------------+------------+---------+--------+---------------+ OUTPUT, diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index f93ab5ec..ba6735ba 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -110,7 +110,7 @@ class GetShortUrlVisitsCommandTest extends TestCase +---------+---------------------------+------------+---------+--------+ | Referer | Date | User agent | Country | City | +---------+---------------------------+------------+---------+--------+ - | foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | + | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | +---------+---------------------------+------------+---------+--------+ OUTPUT, diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index a2dc059f..9b79f509 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -57,7 +57,7 @@ class GetTagVisitsCommandTest extends TestCase +---------+---------------------------+------------+---------+--------+---------------+ | Referer | Date | User agent | Country | City | Short Url | +---------+---------------------------+------------+---------+--------+---------------+ - | foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | +---------+---------------------------+------------+---------+--------+---------------+ OUTPUT, diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 439b33bd..0462c2c0 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -56,7 +56,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase +---------+---------------------------+------------+---------+--------+---------------+ | Referer | Date | User agent | Country | City | Short Url | +---------+---------------------------+------------+---------+--------+---------------+ - | foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | +---------+---------------------------+------------+---------+--------+---------------+ OUTPUT, diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index a9e2a50c..29914b61 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -54,7 +54,7 @@ class GetOrphanVisitsCommandTest extends TestCase +---------+---------------------------+------------+---------+--------+----------+ | Referer | Date | User agent | Country | City | Type | +---------+---------------------------+------------+---------+--------+----------+ - | foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | base_url | + | foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | base_url | +---------+---------------------------+------------+---------+--------+----------+ OUTPUT, diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 7a9c3b92..16da0a09 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -139,7 +139,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface $importedVisits = 0; foreach ($iterable as $importedOrphanVisit) { // Skip visits which are older than the most recent already imported visit's date - if ($mostRecentOrphanVisit?->getDate()->greaterThanOrEquals(normalizeDate($importedOrphanVisit->date))) { + if ($mostRecentOrphanVisit?->date->greaterThanOrEquals(normalizeDate($importedOrphanVisit->date))) { continue; } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index ed97f9f5..084acabd 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -209,7 +209,7 @@ class ShortUrl extends AbstractEntity ->setMaxResults(1); $visit = $this->visits->matching($criteria)->last(); - return $visit instanceof Visit ? $visit->getDate() : null; + return $visit instanceof Visit ? $visit->date : null; } /** diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 86854945..be8400dc 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -29,8 +29,7 @@ class Visit extends AbstractEntity implements JsonSerializable public readonly ?string $remoteAddr = null, public readonly ?string $visitedUrl = null, private ?VisitLocation $visitLocation = null, - // TODO Make public readonly once VisitRepositoryTest does not try to set it - private Chronos $date = new Chronos(), + public readonly Chronos $date = new Chronos(), ) { } @@ -147,14 +146,6 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->type; } - /** - * @internal - */ - public function getDate(): Chronos - { - return $this->date; - } - public function jsonSerialize(): array { $base = [ diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 1506dd1a..9dc18390 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -6,7 +6,6 @@ namespace ShlinkioDbTest\Shlink\Core\Visit\Repository; use Cake\Chronos\Chronos; use PHPUnit\Framework\Attributes\Test; -use ReflectionObject; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -371,15 +370,15 @@ class VisitRepositoryTest extends DatabaseTestCase $botsCount = 3; for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()), + fn () => Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forInvalidShortUrl(Visitor::emptyInstance()), + fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forRegularNotFound(Visitor::emptyInstance()), + fn () => Visit::forRegularNotFound(Visitor::emptyInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); @@ -429,15 +428,15 @@ class VisitRepositoryTest extends DatabaseTestCase for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forBasePath(Visitor::emptyInstance()), + fn () => Visit::forBasePath(Visitor::emptyInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forInvalidShortUrl(Visitor::emptyInstance()), + fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forRegularNotFound(Visitor::emptyInstance()), + fn () => Visit::forRegularNotFound(Visitor::emptyInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); } @@ -566,7 +565,7 @@ class VisitRepositoryTest extends DatabaseTestCase { for ($i = 0; $i < $amount; $i++) { $visit = $this->setDateOnVisit( - Visit::forValidShortUrl( + fn () => Visit::forValidShortUrl( $shortUrl, $botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(), ), @@ -578,12 +577,14 @@ class VisitRepositoryTest extends DatabaseTestCase } } - private function setDateOnVisit(Visit $visit, Chronos $date): Visit + /** + * @param callable(): Visit $createVisit + */ + private function setDateOnVisit(callable $createVisit, Chronos $date): Visit { - $ref = new ReflectionObject($visit); - $dateProp = $ref->getProperty('date'); - $dateProp->setAccessible(true); - $dateProp->setValue($visit, $date); + Chronos::setTestNow($date); + $visit = $createVisit(); + Chronos::setTestNow(); return $visit; } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 2dd1c0c7..bd3c82c8 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -76,7 +76,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'referer' => '', 'userAgent' => '', 'visitLocation' => null, - 'date' => $visit->getDate()->toAtomString(), + 'date' => $visit->date->toAtomString(), 'potentialBot' => false, 'visitedUrl' => '', ], @@ -100,7 +100,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'referer' => '', 'userAgent' => '', 'visitLocation' => null, - 'date' => $orphanVisit->getDate()->toAtomString(), + 'date' => $orphanVisit->date->toAtomString(), 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl, 'type' => $orphanVisit->type->value, diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index d9c50af6..923b2e6b 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -26,7 +26,7 @@ class VisitTest extends TestCase self::assertEquals([ 'referer' => 'some site', - 'date' => $visit->getDate()->toAtomString(), + 'date' => $visit->date->toAtomString(), 'userAgent' => $userAgent, 'visitLocation' => null, 'potentialBot' => $expectedToBePotentialBot, @@ -58,7 +58,7 @@ class VisitTest extends TestCase $visit = Visit::forBasePath(Visitor::emptyInstance()), [ 'referer' => '', - 'date' => $visit->getDate()->toAtomString(), + 'date' => $visit->date->toAtomString(), 'userAgent' => '', 'visitLocation' => null, 'potentialBot' => false, @@ -74,7 +74,7 @@ class VisitTest extends TestCase )), [ 'referer' => 'bar', - 'date' => $visit->getDate()->toAtomString(), + 'date' => $visit->date->toAtomString(), 'userAgent' => 'foo', 'visitLocation' => null, 'potentialBot' => false, @@ -92,7 +92,7 @@ class VisitTest extends TestCase )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), [ 'referer' => 'referer', - 'date' => $visit->getDate()->toAtomString(), + 'date' => $visit->date->toAtomString(), 'userAgent' => 'user-agent', 'visitLocation' => $location, 'potentialBot' => false, diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 6076f95e..9972e3a8 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; -use ReflectionObject; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -50,27 +49,31 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface ); $manager->persist($this->setVisitDate( - Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')), + fn () => 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://s.test/foo/bar', '1.2.3.4', '')), + fn () => 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://s.test/foo', '1.2.3.4', 'foo.com')), + fn () => Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')), '2020-03-01', )); $manager->flush(); } - private function setVisitDate(Visit $visit, string $date): Visit + /** + * @param callable(): Visit $createVisit + */ + private function setVisitDate(callable $createVisit, string $date): Visit { - $ref = new ReflectionObject($visit); - $dateProp = $ref->getProperty('date'); - $dateProp->setAccessible(true); - $dateProp->setValue($visit, Chronos::parse($date)); + Chronos::setTestNow($date); + $visit = $createVisit(); + Chronos::setTestNow(); return $visit; } From 4fdbcc25a0f33a76bc54212eebdca89409d35de3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Apr 2024 18:47:12 +0200 Subject: [PATCH 56/64] Pass visit date to matomo when tracking --- module/Core/src/Matomo/MatomoVisitSender.php | 3 ++- module/Core/test/Matomo/MatomoVisitSenderTest.php | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php index 8caed3cc..c051516c 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -23,7 +23,8 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface ->setUrl($this->resolveUrlToTrack($visit)) ->setCustomTrackingParameter('type', $visit->type->value) ->setUserAgent($visit->userAgent) - ->setUrlReferrer($visit->referer); + ->setUrlReferrer($visit->referer) + ->setForceVisitDateTime($visit->date->setTimezone('UTC')->toDateTimeString()); $location = $visit->getVisitLocation(); if ($location !== null) { diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 3e08d6aa..90c52446 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -42,6 +42,9 @@ class MatomoVisitSenderTest extends TestCase $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); $tracker->expects($this->once())->method('doTrackPageView')->with($visit->shortUrl?->title() ?? ''); + $tracker->expects($this->once())->method('setForceVisitDateTime')->with( + $visit->date->setTimezone('UTC')->toDateTimeString(), + ); if ($visit->isOrphan()) { $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ @@ -93,6 +96,9 @@ class MatomoVisitSenderTest extends TestCase $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); $tracker->expects($this->once())->method('doTrackPageView'); + $tracker->expects($this->once())->method('setForceVisitDateTime')->with( + $visit->date->setTimezone('UTC')->toDateTimeString(), + ); $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); From 6121efec59942a76ed7b196a67e047923ba87b22 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 09:55:40 +0200 Subject: [PATCH 57/64] Create command to send visits to matomo --- module/CLI/config/cli.config.php | 2 + module/CLI/config/dependencies.config.php | 8 + .../Integration/MatomoSendVisitsCommand.php | 144 ++++++++++++++++++ module/Core/config/dependencies.config.php | 6 +- .../Matomo/SendVisitToMatomo.php | 2 +- module/Core/src/Matomo/MatomoVisitSender.php | 32 +++- .../src/Matomo/MatomoVisitSenderInterface.php | 12 +- .../src/Matomo/Model/SendVisitsResult.php | 33 ++++ .../VisitSendingProgressTrackerInterface.php | 14 ++ .../Matomo/SendVisitToMatomoTest.php | 8 +- .../test/Matomo/MatomoVisitSenderTest.php | 9 +- 11 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php create mode 100644 module/Core/src/Matomo/Model/SendVisitsResult.php create mode 100644 module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 63b2de6f..2ee33a1d 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -42,6 +42,8 @@ return [ Command\RedirectRule\ManageRedirectRulesCommand::NAME => Command\RedirectRule\ManageRedirectRulesCommand::class, + + Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class, ], ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 875c8226..f9b90dac 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -8,6 +8,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; +use Shlinkio\Shlink\Core\Matomo; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; @@ -71,6 +72,8 @@ return [ Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class, + + Command\Integration\MatomoSendVisitsCommand::class => ConfigAbstractFactory::class, ], ], @@ -129,6 +132,11 @@ return [ RedirectRule\RedirectRuleHandler::class, ], + Command\Integration\MatomoSendVisitsCommand::class => [ + Matomo\MatomoOptions::class, + Matomo\MatomoVisitSender::class, + ], + Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, Util\ProcessRunner::class, diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php new file mode 100644 index 00000000..cacfb9d4 --- /dev/null +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -0,0 +1,144 @@ +matomoEnabled = $matomoOptions->enabled; + parent::__construct(); + } + + protected function configure(): void + { + $help = <<%command.name% + + Send all visits created before 2024: + %command.name% --until 2023-12-31 + + Send all visits created after a specific day: + %command.name% --since 2022-03-27 + + Send all visits created during 2022: + %command.name% --since 2022-01-01 --until 2022-12-31 + HELP; + + $this + ->setName(self::NAME) + ->setDescription(sprintf( + '%sSend existing visits to the configured matomo instance', + $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ', + )) + ->setHelp($help) + ->addOption( + 'since', + 's', + InputOption::VALUE_REQUIRED, + 'Only visits created since this date, inclusively, will be sent to Matomo', + ) + ->addOption( + 'until', + 'u', + InputOption::VALUE_REQUIRED, + 'Only visits created until this date, inclusively, will be sent to Matomo', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (! $this->matomoEnabled) { + $io->warning('Matomo integration is not enabled in this Shlink instance'); + return ExitCode::EXIT_WARNING; + } + + // TODO Validate provided date formats + $since = $input->getOption('since'); + $until = $input->getOption('until'); + $dateRange = buildDateRange( + startDate: $since !== null ? Chronos::parse($since) : null, + endDate: $until !== null ? Chronos::parse($until) : null, + ); + + if ($input->isInteractive()) { + // TODO Display the resolved date range in case it didn't fail to parse but the value was incorrect + $io->warning([ + 'You are about to send visits in this Shlink instance to Matomo', + 'Shlink will not check for already sent visits, which could result in some duplications. Make sure ' + . 'you have verified only visits in the right date range are going to be sent.', + ]); + if (! $io->confirm('Continue?', default: false)) { + return ExitCode::EXIT_WARNING; + } + } + + $result = $this->visitSender->sendVisitsInDateRange( + $dateRange, + new class ($io, $this->getApplication()) implements VisitSendingProgressTrackerInterface { + public function __construct(private readonly SymfonyStyle $io, private readonly ?Application $app) + { + } + + public function success(int $index): void + { + $this->io->write('.'); + } + + public function error(int $index, Throwable $e): void + { + $this->io->write('E'); + if ($this->io->isVerbose()) { + $this->app?->renderThrowable($e, $this->io); + } + } + }, + ); + + match (true) { + $result->hasFailures() && $result->hasSuccesses() => $io->warning( + sprintf('%s visits sent to Matomo. %s failed', $result->successfulVisits, $result->failedVisits), + ), + $result->hasFailures() => $io->error( + sprintf('%s visits failed to be sent to Matomo.', $result->failedVisits), + ), + $result->hasSuccesses() => $io->success(sprintf('%s visits sent to Matomo.', $result->successfulVisits)), + default => $io->info('There was no visits matching provided date range'), + }; + + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 8a333824..5fcc8e44 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -112,7 +112,11 @@ return [ ConfigAbstractFactory::class => [ Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class], - Matomo\MatomoVisitSender::class => [Matomo\MatomoTrackerBuilder::class, ShortUrlStringifier::class], + Matomo\MatomoVisitSender::class => [ + Matomo\MatomoTrackerBuilder::class, + ShortUrlStringifier::class, + Visit\Repository\VisitIterationRepository::class, + ], ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index c47b9cba..5a85aed4 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -40,7 +40,7 @@ readonly class SendVisitToMatomo } try { - $this->visitSender->sendVisitToMatomo($visit, $visitLocated->originalIpAddress); + $this->visitSender->sendVisit($visit, $visitLocated->originalIpAddress); } catch (Throwable $e) { // Capture all exceptions to make sure this does not interfere with the regular execution $this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]); diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php index c051516c..d2a4484a 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -4,18 +4,48 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Matomo; +use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; +use Throwable; readonly class MatomoVisitSender implements MatomoVisitSenderInterface { public function __construct( private MatomoTrackerBuilderInterface $trackerBuilder, private ShortUrlStringifier $shortUrlStringifier, + private VisitIterationRepositoryInterface $visitIterationRepository, ) { } - public function sendVisitToMatomo(Visit $visit, ?string $originalIpAddress = null): void + /** + * Sends all visits in provided date range to matomo, and returns the amount of affected visits + */ + public function sendVisitsInDateRange( + DateRange $dateRange, + VisitSendingProgressTrackerInterface|null $progressTracker = null, + ): SendVisitsResult { + $visitsIterator = $this->visitIterationRepository->findAllVisits($dateRange); + $successfulVisits = 0; + $failedVisits = 0; + + foreach ($visitsIterator as $index => $visit) { + try { + $this->sendVisit($visit); + $progressTracker?->success($index); + $successfulVisits++; + } catch (Throwable $e) { + $progressTracker?->error($index, $e); + $failedVisits++; + } + } + + return new SendVisitsResult($successfulVisits, $failedVisits); + } + + public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void { $tracker = $this->trackerBuilder->buildMatomoTracker(); diff --git a/module/Core/src/Matomo/MatomoVisitSenderInterface.php b/module/Core/src/Matomo/MatomoVisitSenderInterface.php index fef16367..e1b1c3cb 100644 --- a/module/Core/src/Matomo/MatomoVisitSenderInterface.php +++ b/module/Core/src/Matomo/MatomoVisitSenderInterface.php @@ -4,9 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Matomo; +use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult; use Shlinkio\Shlink\Core\Visit\Entity\Visit; interface MatomoVisitSenderInterface { - public function sendVisitToMatomo(Visit $visit, ?string $originalIpAddress = null): void; + /** + * Sends all visits in provided date range to matomo, and returns the amount of affected visits + */ + public function sendVisitsInDateRange( + DateRange $dateRange, + VisitSendingProgressTrackerInterface|null $progressTracker = null, + ): SendVisitsResult; + + public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void; } diff --git a/module/Core/src/Matomo/Model/SendVisitsResult.php b/module/Core/src/Matomo/Model/SendVisitsResult.php new file mode 100644 index 00000000..2f6b455b --- /dev/null +++ b/module/Core/src/Matomo/Model/SendVisitsResult.php @@ -0,0 +1,33 @@ + $successfulVisits + * @param int<0, max> $failedVisits + */ + public function __construct(public int $successfulVisits = 0, public int $failedVisits = 0) + { + } + + public function hasSuccesses(): bool + { + return $this->successfulVisits > 0; + } + + public function hasFailures(): bool + { + return $this->failedVisits > 0; + } + + public function count(): int + { + return $this->successfulVisits + $this->failedVisits; + } +} diff --git a/module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php b/module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php new file mode 100644 index 00000000..94686992 --- /dev/null +++ b/module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php @@ -0,0 +1,14 @@ +em->expects($this->never())->method('find'); - $this->visitSender->expects($this->never())->method('sendVisitToMatomo'); + $this->visitSender->expects($this->never())->method('sendVisit'); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); @@ -46,7 +46,7 @@ class SendVisitToMatomoTest extends TestCase public function visitIsNotSentWhenItDoesNotExist(): void { $this->em->expects($this->once())->method('find')->willReturn(null); - $this->visitSender->expects($this->never())->method('sendVisitToMatomo'); + $this->visitSender->expects($this->never())->method('sendVisit'); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->once())->method('warning')->with( 'Tried to send visit with id "{visitId}" to matomo, but it does not exist.', @@ -63,7 +63,7 @@ class SendVisitToMatomoTest extends TestCase $visit = Visit::forBasePath(Visitor::emptyInstance()); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); - $this->visitSender->expects($this->once())->method('sendVisitToMatomo')->with($visit, $originalIpAddress); + $this->visitSender->expects($this->once())->method('sendVisit')->with($visit, $originalIpAddress); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); @@ -85,7 +85,7 @@ class SendVisitToMatomoTest extends TestCase $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( $this->createMock(Visit::class), ); - $this->visitSender->expects($this->once())->method('sendVisitToMatomo')->willThrowException($e); + $this->visitSender->expects($this->once())->method('sendVisit')->willThrowException($e); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->once())->method('error')->with( 'An error occurred while trying to send visit to Matomo. {e}', diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 90c52446..0bad6577 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -18,19 +18,24 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; class MatomoVisitSenderTest extends TestCase { private MockObject & MatomoTrackerBuilderInterface $trackerBuilder; + private MockObject & VisitIterationRepositoryInterface $visitIterationRepository; private MatomoVisitSender $visitSender; protected function setUp(): void { $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + $this->visitIterationRepository = $this->createMock(VisitIterationRepositoryInterface::class); + $this->visitSender = new MatomoVisitSender( $this->trackerBuilder, new ShortUrlStringifier(['hostname' => 's2.test']), + $this->visitIterationRepository, ); } @@ -64,7 +69,7 @@ class MatomoVisitSenderTest extends TestCase $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); - $this->visitSender->sendVisitToMatomo($visit, $originalIpAddress); + $this->visitSender->sendVisit($visit, $originalIpAddress); } public static function provideTrackerMethods(): iterable @@ -102,7 +107,7 @@ class MatomoVisitSenderTest extends TestCase $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); - $this->visitSender->sendVisitToMatomo($visit); + $this->visitSender->sendVisit($visit); } public static function provideUrlsToTrack(): iterable From bbdbafd8db6a5deb3570aa87ca1af65d9e24214d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 19:27:03 +0200 Subject: [PATCH 58/64] Test MatomoVisitSender::sendVisitsInDateRange --- .../test/Matomo/MatomoVisitSenderTest.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 0bad6577..aebdf7e0 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -4,11 +4,14 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Matomo; +use Exception; +use Laminas\Validator\Date; use MatomoTracker; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSender; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -127,4 +130,45 @@ class MatomoVisitSenderTest extends TestCase 'http://s2.test/bar', ]; } + + #[Test] + public function multipleVisitsCanBeSent(): void + { + $dateRange = DateRange::allTime(); + $visitor = Visitor::emptyInstance(); + $bot = Visitor::botInstance(); + + $this->visitIterationRepository->expects($this->once())->method('findAllVisits')->with($dateRange)->willReturn([ + Visit::forBasePath($bot), + Visit::forValidShortUrl(ShortUrl::createFake(), $visitor), + Visit::forInvalidShortUrl($visitor), + ]); + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->method('setUrl')->willReturn($tracker); + $tracker->method('setUserAgent')->willReturn($tracker); + $tracker->method('setUrlReferrer')->willReturn($tracker); + $tracker->method('setCustomTrackingParameter')->willReturn($tracker); + + $callCount = 0; + $this->trackerBuilder->expects($this->exactly(3))->method('buildMatomoTracker')->willReturnCallback( + function () use (&$callCount, $tracker) { + $callCount++; + + if ($callCount === 2) { + throw new Exception('Error'); + } + + return $tracker; + }, + ); + + $result = $this->visitSender->sendVisitsInDateRange($dateRange); + + self::assertEquals(2, $result->successfulVisits); + self::assertEquals(1, $result->failedVisits); + self::assertCount(3, $result); + self::assertTrue($result->hasSuccesses()); + self::assertTrue($result->hasFailures()); + } } From f0e62004d58d7fc915cca1a17e65c9583ca79067 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 20:30:00 +0200 Subject: [PATCH 59/64] Add unit test to MatomoSendVisitsCommand --- .../Integration/MatomoSendVisitsCommand.php | 66 ++++----- .../MatomoSendVisitsCommandTest.php | 135 ++++++++++++++++++ module/Core/functions/functions.php | 17 +++ .../test/Matomo/MatomoVisitSenderTest.php | 1 - 4 files changed, 183 insertions(+), 36 deletions(-) create mode 100644 module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php index cacfb9d4..ba9a794e 100644 --- a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -9,7 +9,6 @@ use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface; -use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -18,13 +17,15 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; use function Shlinkio\Shlink\Common\buildDateRange; +use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly; use function sprintf; -class MatomoSendVisitsCommand extends Command +class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface { public const NAME = 'integration:matomo:send-visits'; private readonly bool $matomoEnabled; + private SymfonyStyle $io; public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender) { @@ -79,10 +80,10 @@ class MatomoSendVisitsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); + $this->io = new SymfonyStyle($input, $output); if (! $this->matomoEnabled) { - $io->warning('Matomo integration is not enabled in this Shlink instance'); + $this->io->warning('Matomo integration is not enabled in this Shlink instance'); return ExitCode::EXIT_WARNING; } @@ -95,50 +96,45 @@ class MatomoSendVisitsCommand extends Command ); if ($input->isInteractive()) { - // TODO Display the resolved date range in case it didn't fail to parse but the value was incorrect - $io->warning([ - 'You are about to send visits in this Shlink instance to Matomo', + $this->io->warning([ + 'You are about to send visits from this Shlink instance to Matomo', + 'Resolved date range -> ' . dateRangeToHumanFriendly($dateRange), 'Shlink will not check for already sent visits, which could result in some duplications. Make sure ' . 'you have verified only visits in the right date range are going to be sent.', ]); - if (! $io->confirm('Continue?', default: false)) { + if (! $this->io->confirm('Continue?', default: false)) { return ExitCode::EXIT_WARNING; } } - $result = $this->visitSender->sendVisitsInDateRange( - $dateRange, - new class ($io, $this->getApplication()) implements VisitSendingProgressTrackerInterface { - public function __construct(private readonly SymfonyStyle $io, private readonly ?Application $app) - { - } - - public function success(int $index): void - { - $this->io->write('.'); - } - - public function error(int $index, Throwable $e): void - { - $this->io->write('E'); - if ($this->io->isVerbose()) { - $this->app?->renderThrowable($e, $this->io); - } - } - }, - ); + $result = $this->visitSender->sendVisitsInDateRange($dateRange, $this); match (true) { - $result->hasFailures() && $result->hasSuccesses() => $io->warning( - sprintf('%s visits sent to Matomo. %s failed', $result->successfulVisits, $result->failedVisits), + $result->hasFailures() && $result->hasSuccesses() => $this->io->warning( + sprintf('%s visits sent to Matomo. %s failed.', $result->successfulVisits, $result->failedVisits), ), - $result->hasFailures() => $io->error( - sprintf('%s visits failed to be sent to Matomo.', $result->failedVisits), + $result->hasFailures() => $this->io->error( + sprintf('Failed to send %s visits to Matomo.', $result->failedVisits), ), - $result->hasSuccesses() => $io->success(sprintf('%s visits sent to Matomo.', $result->successfulVisits)), - default => $io->info('There was no visits matching provided date range'), + $result->hasSuccesses() => $this->io->success( + sprintf('%s visits sent to Matomo.', $result->successfulVisits), + ), + default => $this->io->info('There was no visits matching provided date range.'), }; return ExitCode::EXIT_SUCCESS; } + + public function success(int $index): void + { + $this->io->write('.'); + } + + public function error(int $index, Throwable $e): void + { + $this->io->write('E'); + if ($this->io->isVerbose()) { + $this->getApplication()?->renderThrowable($e, $this->io); + } + } } diff --git a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php new file mode 100644 index 00000000..e3a52733 --- /dev/null +++ b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php @@ -0,0 +1,135 @@ +visitSender = $this->createMock(MatomoVisitSenderInterface::class); + } + + #[Test] + public function warningDisplayedIfIntegrationIsNotEnabled(): void + { + [$output, $exitCode] = $this->executeCommand(matomoEnabled: false); + + self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output); + self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); + } + + #[Test] + #[TestWith([true])] + #[TestWith([false])] + public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void + { + $this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult()); + + [$output] = $this->executeCommand(['y'], ['interactive' => $interactive]); + + if ($interactive) { + self::assertStringContainsString('You are about to send visits', $output); + } else { + self::assertStringNotContainsString('You are about to send visits', $output); + } + } + + #[Test] + #[TestWith([true])] + #[TestWith([false])] + public function canCancelExecutionInInteractiveMode(bool $interactive): void + { + $this->visitSender->expects($this->exactly($interactive ? 0 : 1))->method('sendVisitsInDateRange')->willReturn( + new SendVisitsResult(), + ); + $this->executeCommand(['n'], ['interactive' => $interactive]); + } + + #[Test] + #[TestWith([new SendVisitsResult(), 'There was no visits matching provided date range'])] + #[TestWith([new SendVisitsResult(successfulVisits: 10), '10 visits sent to Matomo.'])] + #[TestWith([new SendVisitsResult(successfulVisits: 2), '2 visits sent to Matomo.'])] + #[TestWith([new SendVisitsResult(failedVisits: 238), 'Failed to send 238 visits to Matomo.'])] + #[TestWith([new SendVisitsResult(failedVisits: 18), 'Failed to send 18 visits to Matomo.'])] + #[TestWith([new SendVisitsResult(successfulVisits: 2, failedVisits: 35), '2 visits sent to Matomo. 35 failed.'])] + #[TestWith([new SendVisitsResult(successfulVisits: 81, failedVisits: 6), '81 visits sent to Matomo. 6 failed.'])] + public function expectedResultIsDisplayed(SendVisitsResult $result, string $expectedResultMessage): void + { + $this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturn($result); + [$output, $exitCode] = $this->executeCommand(['y']); + + self::assertStringContainsString($expectedResultMessage, $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + } + + #[Test] + public function printsResultOfSendingVisits(): void + { + $this->visitSender->method('sendVisitsInDateRange')->willReturnCallback( + function (DateRange $_, MatomoSendVisitsCommand $command): SendVisitsResult { + // Call it a few times for an easier match of its result in the command putput + $command->success(0); + $command->success(1); + $command->success(2); + $command->error(3, new Exception('Error')); + $command->success(4); + $command->error(5, new Exception('Error')); + + return new SendVisitsResult(); + }, + ); + + [$output] = $this->executeCommand(['y']); + + self::assertStringContainsString('...E.E', $output); + } + + #[Test] + #[TestWith([[], 'All time'])] + #[TestWith([['--since' => '2023-05-01'], 'Since 2023-05-01 00:00:00'])] + #[TestWith([['--until' => '2023-05-01'], 'Until 2023-05-01 00:00:00'])] + #[TestWith([ + ['--since' => '2023-05-01', '--until' => '2024-02-02 23:59:59'], + 'Between 2023-05-01 00:00:00 and 2024-02-02 23:59:59', + ])] + public function providedDateAreParsed(array $args, string $expectedMessage): void + { + [$output] = $this->executeCommand(['n'], args: $args); + self::assertStringContainsString('Resolved date range -> ' . $expectedMessage, $output); + } + + /** + * @return array{string, int, MatomoSendVisitsCommand} + */ + private function executeCommand( + array $input = [], + array $options = [], + array $args = [], + bool $matomoEnabled = true, + ): array { + $command = new MatomoSendVisitsCommand(new MatomoOptions(enabled: $matomoEnabled), $this->visitSender); + $commandTester = CliTestUtils::testerForCommand($command); + $commandTester->setInputs($input); + $commandTester->execute($args, $options); + + $output = $commandTester->getDisplay(); + $exitCode = $commandTester->getStatusCode(); + + return [$output, $exitCode, $command]; + } +} diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 5ba45ac2..2e238e99 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -61,6 +61,23 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } +function dateRangeToHumanFriendly(?DateRange $dateRange): string +{ + $startDate = $dateRange?->startDate; + $endDate = $dateRange?->endDate; + + return match (true) { + $startDate !== null && $endDate !== null => sprintf( + 'Between %s and %s', + $startDate->toDateTimeString(), + $endDate->toDateTimeString(), + ), + $startDate !== null => sprintf('Since %s', $startDate->toDateTimeString()), + $endDate !== null => sprintf('Until %s', $endDate->toDateTimeString()), + default => 'All time', + }; +} + /** * @return ($date is null ? null : Chronos) */ diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index aebdf7e0..816c8eea 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Matomo; use Exception; -use Laminas\Validator\Date; use MatomoTracker; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; From 82e7094f3a5ad1910c9d4528a3e986446ed6bee4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 20:48:03 +0200 Subject: [PATCH 60/64] Fix VisitIterationRepositoryTest for MS SQL --- .../src/Visit/Repository/VisitIterationRepository.php | 8 ++++---- .../Visit/Repository/VisitIterationRepositoryTest.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/module/Core/src/Visit/Repository/VisitIterationRepository.php b/module/Core/src/Visit/Repository/VisitIterationRepository.php index 0431788c..cf342611 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepository.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepository.php @@ -51,12 +51,12 @@ class VisitIterationRepository extends EntitySpecificationRepository implements { $qb = $this->createQueryBuilder('v'); if ($dateRange?->startDate !== null) { - $qb->andWhere($qb->expr()->gte('v.date', ':since')); - $qb->setParameter('since', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); + $qb->andWhere($qb->expr()->gte('v.date', ':since')) + ->setParameter('since', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); } if ($dateRange?->endDate !== null) { - $qb->andWhere($qb->expr()->lte('v.date', ':until')); - $qb->setParameter('until', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); + $qb->andWhere($qb->expr()->lte('v.date', ':until')) + ->setParameter('until', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); } return $this->visitsIterableForQuery($qb, $blockSize); diff --git a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php index 7a683e3c..6d3d4b39 100644 --- a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -55,17 +55,17 @@ class VisitIterationRepositoryTest extends DatabaseTestCase $unlocated = $this->repo->findUnlocatedVisits($blockSize); $all = $this->repo->findAllVisits(blockSize: $blockSize); $lastThreeDays = $this->repo->findAllVisits( - dateRange: DateRange::since(Chronos::now()->subDays(2)), + dateRange: DateRange::since(Chronos::now()->subDays(2)->startOfDay()), blockSize: $blockSize, ); $firstTwoDays = $this->repo->findAllVisits( - dateRange: DateRange::until(Chronos::now()->subDays(4)), + dateRange: DateRange::until(Chronos::now()->subDays(4)->endOfDay()), blockSize: $blockSize, ); $daysInBetween = $this->repo->findAllVisits( dateRange: DateRange::between( - startDate: Chronos::now()->subDays(5), - endDate: Chronos::now()->subDays(2), + startDate: Chronos::now()->subDays(5)->startOfDay(), + endDate: Chronos::now()->subDays(2)->endOfDay(), ), blockSize: $blockSize, ); From dc8dfa9f0ce4102b3a46abebf6f850ba565a3492 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 20:49:34 +0200 Subject: [PATCH 61/64] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b46c26ec..7afac46b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space. +* [#1925](https://github.com/shlinkio/shlink/issues/1925) Add new `integration:matomo:send-visits` console command that can be used to send existing visits to integrated Matomo instance. + ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. * [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement in some endpoints which involve counting visits: From 986f1162ddd89a3154815c53a2585147b8a1e7a4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 20:56:59 +0200 Subject: [PATCH 62/64] Set COLUMNS env var when running unit tests --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 99dc8a88..a8f52bd0 100644 --- a/composer.json +++ b/composer.json @@ -118,7 +118,7 @@ "@parallel test:unit test:db", "@parallel test:api test:cli" ], - "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox", + "test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --colors=always --testdox", "test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov", "test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html", "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", From 93fa27bdba8232ec650a86e42e0a4b710e78a13b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Apr 2024 08:40:52 +0200 Subject: [PATCH 63/64] Add v4.1.0 to changelog --- CHANGELOG.md | 2072 +---------------------- composer.json | 6 +- docs/changelog-archive/CHANGELOG-1.x.md | 1167 +++++++++++++ docs/changelog-archive/CHANGELOG-2.x.md | 912 ++++++++++ 4 files changed, 2083 insertions(+), 2074 deletions(-) create mode 100644 docs/changelog-archive/CHANGELOG-1.x.md create mode 100644 docs/changelog-archive/CHANGELOG-2.x.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7afac46b..743301d2 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] +## [4.1.0] - 2024-04-14 ### Added * [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit. @@ -825,2073 +825,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * *Nothing* - - -## [2.10.3] - 2022-01-23 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1349](https://github.com/shlinkio/shlink/issues/1349) Fixed memory leak in cache implementation. - - -## [2.10.2] - 2022-01-07 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1293](https://github.com/shlinkio/shlink/issues/1293) Fixed error when trying to create/import short URLs with a too long title. -* [#1306](https://github.com/shlinkio/shlink/issues/1306) Ensured remote IP address is not logged when using swoole/openswoole. -* [#1308](https://github.com/shlinkio/shlink/issues/1308) Fixed memory leak when using redis due to the amount of non-expiring keys created by doctrine. Now they have a 24h expiration by default. - - -## [2.10.1] - 2021-12-21 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1285](https://github.com/shlinkio/shlink/issues/1285) Fixed error caused by database connections expiring after some hours of inactivity. -* [#1286](https://github.com/shlinkio/shlink/issues/1286) Fixed `x-request-id` header not being generated during non-rest requests. - - -## [2.10.0] - 2021-12-12 -### Added -* [#1163](https://github.com/shlinkio/shlink/issues/1163) Allowed setting not-found redirects for default domain in the same way it's done for any other domain. - - This implies a few non-breaking changes: - - * The domains list no longer has the values of `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` on the default domain redirects. - * The `GET /domains` endpoint includes a new `defaultRedirects` property in the response, with the default redirects set via config or env vars. - * The `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` env vars are now deprecated, and should be replaced by `DEFAULT_INVALID_SHORT_URL_REDIRECT`, `DEFAULT_REGULAR_404_REDIRECT` and `DEFAULT_BASE_URL_REDIRECT` respectively. Deprecated ones will continue to work until v3.0.0, where they will be removed. - -* [#868](https://github.com/shlinkio/shlink/issues/868) Added support to publish real-time updates in a RabbitMQ server. - - Shlink will create new exchanges and queues for every topic documented in the [Async API spec](https://api-spec.shlink.io/async-api/), meaning, you will have one queue for orphan visits, one for regular visits, and one queue for every short URL with its visits. - - The RabbitMQ server config can be provided via installer config options, or via environment variables. - -* [#1204](https://github.com/shlinkio/shlink/issues/1204) Added support for `openswoole` and migrated official docker image to `openswoole`. -* [#1242](https://github.com/shlinkio/shlink/issues/1242) Added support to import urls and visits from YOURLS. - - In order to do it, you need to first install this [dedicated plugin](https://slnk.to/yourls-import) in YOURLS, and then run the `short-url:import yourls` command, as with any other source. - -* [#1235](https://github.com/shlinkio/shlink/issues/1235) Added support to disable rounding QR codes block sizing via config option, env var or query param. -* [#1188](https://github.com/shlinkio/shlink/issues/1188) Added support for PHP 8.1. - - The official docker image has also been updated to use PHP 8.1 by default. - -### Changed -* [#844](https://github.com/shlinkio/shlink/issues/844) Added mutation checks to API tests. -* [#1218](https://github.com/shlinkio/shlink/issues/1218) Updated to symfony/mercure 0.6. -* [#1223](https://github.com/shlinkio/shlink/issues/1223) Updated to phpstan 1.0. -* [#1258](https://github.com/shlinkio/shlink/issues/1258) Updated to Symfony 6 components, except symfony/console. -* Added `domain` field to `DeleteShortUrlException` exception. - -### Deprecated -* [#1260](https://github.com/shlinkio/shlink/issues/1260) Deprecated `USE_HTTPS` env var that was added in previous release, in favor of the new `IS_HTTPS_ENABLED`. - - The old one proved to be confusing and misleading, making people think it was used to actually enable HTTPS transparently, instead of its actual purpose, which is just telling Shlink it is being served with HTTPS. - -### Removed -* *Nothing* - -### Fixed -* [#1206](https://github.com/shlinkio/shlink/issues/1206) Fixed debugging of the docker image, so that it does not run the commands with `-q` when the `SHELL_VERBOSITY` env var has been provided. -* [#1254](https://github.com/shlinkio/shlink/issues/1254) Fixed examples in swagger docs. - - -## [2.9.3] - 2021-11-15 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1232](https://github.com/shlinkio/shlink/issues/1232) Solved potential SQL injection by enforcing `doctrine/dbal` 3.1.4. - - -## [2.9.2] - 2021-10-23 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1210](https://github.com/shlinkio/shlink/issues/1210) Fixed real time updates not being notified due to an incorrect handling of db transactions on multi-process tasks. -* [#1211](https://github.com/shlinkio/shlink/issues/1211) Fixed `There is no active transaction` error when running migrations in MySQL/Mariadb after updating to doctrine-migrations 3.3. -* [#1197](https://github.com/shlinkio/shlink/issues/1197) Fixed amount of task workers provided via config option or env var not being validated to ensure enough workers to process all parallel tasks. - - -## [2.9.1] - 2021-10-11 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1201](https://github.com/shlinkio/shlink/issues/1201) Fixed crash when using the new `USE_HTTPS`, as it's boolean raw value was being used instead of resolving "https" or "http". - - -## [2.9.0] - 2021-10-10 -### Added -* [#1015](https://github.com/shlinkio/shlink/issues/1015) Shlink now accepts configuration via env vars even when not using docker. - - The config generated with the installing tool still has precedence over the env vars, so it cannot be combined. Either you use the tool, or use env vars. - -* [#1149](https://github.com/shlinkio/shlink/issues/1149) Allowed to set custom defaults for the QR codes. -* [#1112](https://github.com/shlinkio/shlink/issues/1112) Added new option to define if the query string should be forwarded on a per-short URL basis. - - The new `forwardQuery=true|false` param can be provided during short URL creation or edition, via REST API or CLI command, allowing to override the default behavior which makes the query string to always be forwarded. - -* [#1105](https://github.com/shlinkio/shlink/issues/1105) Added support to define placeholders on not-found redirects, so that the redirected URL receives the originally visited path and/or domain. - - Currently, `{DOMAIN}` and `{ORIGINAL_PATH}` placeholders are supported, and they can be used both in the redirected URL's path or query. - - When they are used in the query, the values are URL encoded. - -* [#1119](https://github.com/shlinkio/shlink/issues/1119) Added support to provide redis sentinel when using redis cache. -* [#1016](https://github.com/shlinkio/shlink/issues/1016) Added new option to send orphan visits to webhooks, via `NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS` env var or installer tool. - - The option is disabled by default, as the payload is backwards incompatible. You will need to adapt your webhooks to treat the `shortUrl` property as optional before enabling this option. - -* [#1104](https://github.com/shlinkio/shlink/issues/1104) Added ability to disable tracking based on IP addresses. - - IP addresses can be provided in the form of fixed addresses, CIDR blocks, or wildcard patterns (192.168.*.*). - -### Changed -* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`. -* [#1157](https://github.com/shlinkio/shlink/issues/1157) All routes now support CORS, not only rest ones. -* [#1144](https://github.com/shlinkio/shlink/issues/1144) Added experimental builds under PHP 8.1. - -### Deprecated -* [#1164](https://github.com/shlinkio/shlink/issues/1164) Deprecated `SHORT_DOMAIN_HOST` and `SHORT_DOMAIN_SCHEMA` env vars. Use `DEFAULT_DOMAIN` and `USE_HTTPS=true|false` instead. - -### Removed -* *Nothing* - -### Fixed -* [#1165](https://github.com/shlinkio/shlink/issues/1165) Fixed warning displayed when trying to locate visits and there are none pending. -* [#1172](https://github.com/shlinkio/shlink/pull/1172) Removed unneeded explicitly defined volumes in docker image. - - -## [2.8.1] - 2021-08-15 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1155](https://github.com/shlinkio/shlink/issues/1155) Fixed numeric query params in long URLs being replaced by `0`. - - -## [2.8.0] - 2021-08-04 -### Added -* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`. -* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes. - - Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High. - -* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL. - - With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`. - - This behavior needs to be actively opted in, via installer config options or env vars. - -* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink. - - Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain. - -### Changed -* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. -* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24. -* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14. - -### Deprecated -* *Nothing* - -### Removed -* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4. - -### Fixed -* [#1098](https://github.com/shlinkio/shlink/issues/1098) Fixed errors when using Redis for caching, caused by some third party lib bug that was fixed on dependencies update. - - -## [2.7.3] - 2021-08-02 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1135](https://github.com/shlinkio/shlink/issues/1135) Fixed error when importing short URLs with no visits from another Shlink instance. -* [#1136](https://github.com/shlinkio/shlink/issues/1136) Fixed error when fetching tag/short-url/orphan visits for a page lower than 1. - - -## [2.7.2] - 2021-07-30 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1128](https://github.com/shlinkio/shlink/issues/1128) Increased memory limit reserved for the docker image, preventing it from crashing on GeoLite db download. - - -## [2.7.1] - 2021-05-30 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1100](https://github.com/shlinkio/shlink/issues/1100) Fixed Shlink trying to download GeoLite2 db files even when tracking has been disabled. - - -## [2.7.0] - 2021-05-23 -### Added -* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows. -* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole. - - The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update. - - Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one. - -* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line. -* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API. -* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it. - - In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer: - - * `disable_tracking`: If true, visits will not be tracked at all. - * `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved. - * `disable_referrer_tracking`: If true, the referrer will not be tracked. - * `disable_ua_tracking`: If true, the user agent will not be tracked. - -* [#955](https://github.com/shlinkio/shlink/issues/955) Added new option to set short URLs as crawlable, making them be listed in the robots.txt as Allowed. -* [#900](https://github.com/shlinkio/shlink/issues/900) Shlink now tries to detect if the visit is coming from a potential bot or crawler, and allows to exclude those visits from visits lists if desired. - -### Changed -* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0. -* [#1039](https://github.com/shlinkio/shlink/issues/1039) Updated to `endroid/qr-code` 4.0. -* [#1008](https://github.com/shlinkio/shlink/issues/1008) Ensured all logs are sent to the filesystem while running API tests, which helps debugging the reason for tests to fail. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1041](https://github.com/shlinkio/shlink/issues/1041) Ensured the default value for the version while building the docker image is `latest`. -* [#1067](https://github.com/shlinkio/shlink/issues/1067) Fixed exception when persisting multiple short URLs in one batch which include the same new tags/domains. This can potentially happen when importing URLs. - - -## [2.6.2] - 2021-03-12 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1047](https://github.com/shlinkio/shlink/issues/1047) Fixed error in migrations when doing a fresh installation using PHP8 and MySQL/Mariadb databases. - - -## [2.6.1] - 2021-02-22 -### Added -* *Nothing* - -### Changed -* [#1026](https://github.com/shlinkio/shlink/issues/1026) Removed non-inclusive terms from source code. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#1024](https://github.com/shlinkio/shlink/issues/1024) Fixed migration that is incorrectly skipped due to the wrong condition being used to check it. -* [#1031](https://github.com/shlinkio/shlink/issues/1031) Fixed shortening of twitter URLs with URL validation enabled. -* [#1034](https://github.com/shlinkio/shlink/issues/1034) Fixed warning displayed when shlink is stopped while running it with swoole. - - -## [2.6.0] - 2021-02-13 -### Added -* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support. -* [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL. - - The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in. - -* [#913](https://github.com/shlinkio/shlink/issues/913) Added support to import short URLs from a standard CSV file. - - The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns. - -* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code. -* [#675](https://github.com/shlinkio/shlink/issues/675) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits. - - This behavior is enabled by default, but you can opt out via env vars or config options. - - This new orphan visits can be consumed in these ways: - - * The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs. - * The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits. - -### Changed -* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. -* [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. -* [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes. -* [#874](https://github.com/shlinkio/shlink/issues/874) Changed how dist files are generated. Now there will be two for every supported PHP version, with and without support for swoole. - - The dist files will have been built under the same PHP version they are meant to be run under, ensuring resolved dependencies are the proper ones. - -### Deprecated -* [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`). - - All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0 - -* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated the endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`). - - The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead. - -### Removed -* *Nothing* - -### Fixed -* [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers. -* [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used. -* [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int. -* [#851](https://github.com/shlinkio/shlink/issues/851) Fixed error when trying to schedule swoole tasks in ARM architectures (like raspberry). - - -## [2.5.2] - 2021-01-24 -### Added -* [#965](https://github.com/shlinkio/shlink/issues/965) Added docs section for Architectural Decision Records, including the one for API key roles. - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short URLs list. -* [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address. -* [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods. - - -## [2.5.1] - 2021-01-21 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#968](https://github.com/shlinkio/shlink/issues/968) Fixed index error in MariaDB while updating to v2.5.0. -* [#972](https://github.com/shlinkio/shlink/issues/972) Fixed 500 error when calling single-step short URL creation endpoint. - - -## [2.5.0] - 2021-01-17 -### Added -* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys. - - API keys can have any combinations of these two roles now, allowing to limit their interactions: - - * Can interact only with short URLs created with that API key. - * Can interact only with short URLs for a specific domain. - -* [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database. - - It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image. - -* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10. -* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs. -* [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances. -* [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it. - -### Changed -* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package. -* [#875](https://github.com/shlinkio/shlink/issues/875) Updated to `mezzio/mezzio-swoole` v3.1. -* [#952](https://github.com/shlinkio/shlink/issues/952) Simplified in-project docs, by keeping only the basics and linking to the websites docs for anything else. - -### Deprecated -* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`. -* [#924](https://github.com/shlinkio/shlink/issues/924) Deprecated mechanism to provide config options to the docker image through volumes. Use the env vars instead as a direct replacement. - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [2.4.2] - 2020-11-22 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#904](https://github.com/shlinkio/shlink/issues/904) Explicitly added missing "Domains" and "Integrations" tags to swagger docs. -* [#901](https://github.com/shlinkio/shlink/issues/901) Ensured domains which are not in use on any short URL are not returned on the list of domains. -* [#899](https://github.com/shlinkio/shlink/issues/899) Avoided filesystem errors produced while downloading geolite DB files on several shlink instances that share the same filesystem. -* [#827](https://github.com/shlinkio/shlink/issues/827) Fixed swoole config getting loaded in config cache if a console command is run before any web execution, when swoole extension is enabled, making subsequent non-swoole web requests fail. - - -## [2.4.1] - 2020-11-10 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#891](https://github.com/shlinkio/shlink/issues/891) Fixed error when running migrations in postgres due to incorrect return type hint. -* [#846](https://github.com/shlinkio/shlink/issues/846) Fixed base image used for the PHP-FPM dev container. -* [#867](https://github.com/shlinkio/shlink/issues/867) Fixed not-found redirects not using proper status (301 or 302) as configured during installation. - - -## [2.4.0] - 2020-11-08 -### Added -* [#829](https://github.com/shlinkio/shlink/issues/829) Added support for QR codes in SVG format, by passing `?format=svg` to the QR code URL. -* [#820](https://github.com/shlinkio/shlink/issues/820) Added new option to force enabling or disabling URL validation on a per-URL basis. - - Currently, there's a global config that tells if long URLs should be validated (by ensuring they are publicly accessible and return a 2xx status). However, this is either always applied or never applied. - - Now, it is possible to enforce validation or enforce disabling validation when a new short URL is created or edited: - - * On the `POST /short-url` and `PATCH /short-url/{shortCode}` endpoints, you can now pass `validateUrl: true/false` in order to enforce enabling or disabling validation, ignoring the global config. If the value is not provided, the global config is still normally applied. - * On the `short-url:generate` CLI command, you can pass `--validate-url` or `--no-validate-url` flags, in order to enforce enabling or disabling validation. If none of them is provided, the global config is still normally applied. - -* [#838](https://github.com/shlinkio/shlink/issues/838) Added new endpoint and CLI command to list existing domains. - - It returns both default domain and specific domains that were used for some short URLs. - - * REST endpoint: `GET /rest/v2/domains` - * CLI Command: `domain:list` - -* [#832](https://github.com/shlinkio/shlink/issues/832) Added support to customize the port in which the docker image listens by using the `PORT` env var or the `port` config option. - -* [#860](https://github.com/shlinkio/shlink/issues/860) Added support to import links from bit.ly. - - Run the command `short-urls:import bitly` and introduce requested information in order to import all your links. - - Other sources will be supported in future releases. - -### Changed -* [#836](https://github.com/shlinkio/shlink/issues/836) Added support for the `-` notation while determining how to order the short URLs list, as in `?orderBy=shortCode-DESC`. This effectively deprecates the array notation (`?orderBy[shortCode]=DESC`), that will be removed in Shlink 3.0.0 -* [#782](https://github.com/shlinkio/shlink/issues/782) Added code coverage to API tests. -* [#858](https://github.com/shlinkio/shlink/issues/858) Updated to latest infection version. Updated docker images to PHP 7.4.11 and swoole 4.5.5 -* [#887](https://github.com/shlinkio/shlink/pull/887) Started tracking the API key used to create short URLs, in order to allow restrictions in future releases. - -### Deprecated -* [#883](https://github.com/shlinkio/shlink/issues/883) Deprecated `POST /tags` endpoint and `tag:create` command, as tags are created automatically while creating short URLs. - -### Removed -* *Nothing* - -### Fixed -* [#837](https://github.com/shlinkio/shlink/issues/837) Drastically improved performance when creating a new shortUrl and providing `findIfExists = true`. -* [#878](https://github.com/shlinkio/shlink/issues/878) Added missing `gmp` extension to the official docker image. - - -## [2.3.0] - 2020-08-09 -### Added -* [#746](https://github.com/shlinkio/shlink/issues/746) Allowed to configure the kind of redirect you want to use for your short URLs. You can either set: - - * `302` redirects: Default behavior. Visitors always hit the server. - * `301` redirects: Better for SEO. Visitors hit the server the first time and then cache the redirect. - - When selecting 301 redirects, you can also configure the time redirects are cached, to mitigate deviations in stats. - -* [#734](https://github.com/shlinkio/shlink/issues/734) Added support to redirect to deeplinks and other links with schemas different from `http` and `https`. -* [#709](https://github.com/shlinkio/shlink/issues/709) Added multi-architecture builds for the docker image. - -* [#707](https://github.com/shlinkio/shlink/issues/707) Added `--all` flag to `short-urls:list` command, which will print all existing URLs in one go, with no pagination. - - It has one limitation, though. Because of the way the CLI tooling works, all rows in the table must be loaded in memory. If the amount of URLs is too high, the command may fail due to too much memory usage. - -### Changed -* [#508](https://github.com/shlinkio/shlink/issues/508) Added mutation checks to database tests. -* [#790](https://github.com/shlinkio/shlink/issues/790) Updated to doctrine/migrations v3. -* [#798](https://github.com/shlinkio/shlink/issues/798) Updated to guzzlehttp/guzzle v7. -* [#822](https://github.com/shlinkio/shlink/issues/822) Updated docker image to use PHP 7.4.9 with Alpine 3.12 and swoole 4.5.2. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [2.2.2] - 2020-06-08 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#769](https://github.com/shlinkio/shlink/issues/769) Fixed custom slugs not allowing valid URL characters, like `.`, `_` or `~`. -* [#781](https://github.com/shlinkio/shlink/issues/781) Fixed memory leak when loading visits for a tag which is used for big amounts of short URLs. - - -## [2.2.1] - 2020-05-11 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#764](https://github.com/shlinkio/shlink/issues/764) Fixed error when trying to match an existing short URL which does not have `validSince` and/or `validUntil`, but you are providing either one of them for the new one. - - -## [2.2.0] - 2020-05-09 -### Added -* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server. - - Thanks to that, Shlink will be able to publish events that can be consumed in real time. - - For now, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl: - - * A visit occurs on any short URL: `https://shlink.io/new-visit`. - * A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`. - - The updates are only published when serving Shlink with swoole. - - Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subscribe to updates. - -* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats. -* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag. - - It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag. - -* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag. - - Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag. - - Also, the `tag:list` CLI command has been changed and it always behaves like this. - -* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation. - -### Changed -* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets. -* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered. -* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql. -* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled. -* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired. -* [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer. - - -## [2.1.4] - 2020-04-30 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#742](https://github.com/shlinkio/shlink/issues/742) Allowed a custom GeoLite2 license key to be provided, in order to avoid download limits. - - -## [2.1.3] - 2020-04-09 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#712](https://github.com/shlinkio/shlink/issues/712) Fixed app set-up not clearing entities metadata cache. -* [#711](https://github.com/shlinkio/shlink/issues/711) Fixed `HEAD` requests returning a duplicated `Content-Length` header. -* [#716](https://github.com/shlinkio/shlink/issues/716) Fixed Twitter not properly displaying preview for final long URL. -* [#717](https://github.com/shlinkio/shlink/issues/717) Fixed DB connection expiring on task workers when using swoole. -* [#705](https://github.com/shlinkio/shlink/issues/705) Fixed how the short URL domain is inferred when generating QR codes, making sure the configured domain is respected even if the request is performed using a different one, and only when a custom domain is used, then that one is used instead. - - -## [2.1.2] - 2020-03-29 -### Added -* *Nothing* - -### Changed -* [#696](https://github.com/shlinkio/shlink/issues/696) Updated to infection v0.16. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#700](https://github.com/shlinkio/shlink/issues/700) Fixed migration not working with postgres. -* [#690](https://github.com/shlinkio/shlink/issues/690) Fixed tags being incorrectly sluggified when filtering short URL lists, making results not be the expected. - - -## [2.1.1] - 2020-03-28 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#697](https://github.com/shlinkio/shlink/issues/697) Recovered `.htaccess` file that was unintentionally removed in v2.1.0, making Shlink unusable with Apache. - - -## [2.1.0] - 2020-03-28 -### Added -* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server. -* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis. -* [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries. -* [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole. -* [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint. - -### Changed -* [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9. -* [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`. - - * When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue. - * When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed. -* [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug. -* [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way. - - -## [2.0.5] - 2020-02-09 -### Added -* [#651](https://github.com/shlinkio/shlink/issues/651) Documented how Shlink behaves when using multiple domains. - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#648](https://github.com/shlinkio/shlink/issues/648) Ensured any user can write in log files, in case shlink is run by several system users. -* [#650](https://github.com/shlinkio/shlink/issues/650) Ensured default domain is ignored when trying to create a short URL. - - -## [2.0.4] - 2020-02-02 -### Added -* *Nothing* - -### Changed -* [#577](https://github.com/shlinkio/shlink/issues/577) Wrapped params used to customize short URL lists into a DTO with implicit validation. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#620](https://github.com/shlinkio/shlink/issues/620) Ensured "controlled" errors (like validation errors and such) won't be logged with error level, preventing logs to be polluted. -* [#637](https://github.com/shlinkio/shlink/issues/637) Fixed several work flows in which short URLs with domain are handled form the API. -* [#644](https://github.com/shlinkio/shlink/issues/644) Fixed visits to short URL on non-default domain being linked to the URL on default domain with the same short code. -* [#643](https://github.com/shlinkio/shlink/issues/643) Fixed searching on short URL lists not taking into consideration the domain name. - - -## [2.0.3] - 2020-01-27 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected. -* [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset. -* [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled. -* [#621](https://github.com/shlinkio/shlink/issues/621) Fixed permission denied error when updating same GeoLite file version more than once. - - -## [2.0.2] - 2020-01-12 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#614](https://github.com/shlinkio/shlink/issues/614) Fixed `OPTIONS` requests including the `Origin` header not always returning an empty body with status 2xx. -* [#615](https://github.com/shlinkio/shlink/issues/615) Fixed query args with no value being lost from the long URL when users are redirected. - - -## [2.0.1] - 2020-01-10 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#607](https://github.com/shlinkio/shlink/issues/607) Added missing info on UPGRADE.md doc. -* [#610](https://github.com/shlinkio/shlink/issues/610) Fixed use of hardcoded quotes on a database migration which makes it fail on postgres. -* [#605](https://github.com/shlinkio/shlink/issues/605) Fixed crashes occurring when migrating from old Shlink versions with nullable DB columns that are assigned to non-nullable entity typed props. - - -## [2.0.0] - 2020-01-08 -### Added -* [#429](https://github.com/shlinkio/shlink/issues/429) Added support for PHP 7.4 -* [#529](https://github.com/shlinkio/shlink/issues/529) Created an UPGRADING.md file explaining how to upgrade from v1.x to v2.x -* [#594](https://github.com/shlinkio/shlink/issues/594) Updated external shlink packages, including installer v4.0, which adds the option to ask for the redis cluster config. - -### Changed -* [#592](https://github.com/shlinkio/shlink/issues/592) Updated coding styles to use [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) v2.1.0. -* [#530](https://github.com/shlinkio/shlink/issues/530) Migrated project from deprecated `zendframework` components to the new `laminas` and `mezzio` ones. - -### Deprecated -* *Nothing* - -### Removed -* [#429](https://github.com/shlinkio/shlink/issues/429) Dropped support for PHP 7.2 and 7.3 - -* [#229](https://github.com/shlinkio/shlink/issues/229) Remove everything which was deprecated, including: - - * Preview generation feature completely removed. - * Authentication against REST API using JWT is no longer supported. - - See [UPGRADE](UPGRADE.md#from-v1x-to-v2x) doc in order to get details on how to migrate to this version. - -### Fixed -* [#600](https://github.com/shlinkio/shlink/issues/600) Fixed health action so that it works with and without version in the path. - - -## [1.21.1] - 2020-01-02 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#596](https://github.com/shlinkio/shlink/issues/596) Fixed error when trying to download GeoLite2 database due to changes on how to get the database files. - - -## [1.21.0] - 2019-12-29 -### Added -* [#118](https://github.com/shlinkio/shlink/issues/118) API errors now implement the [problem details](https://tools.ietf.org/html/rfc7807) standard. - - In order to make it backwards compatible, two things have been done: - - * Both the old `error` and `message` properties have been kept on error response, containing the same values as the `type` and `detail` properties respectively. - * The API `v2` has been enabled. If an error occurs when calling the API with this version, the `error` and `message` properties will not be returned. - - > After Shlink v2 is released, both API versions will behave like API v2. - -* [#575](https://github.com/shlinkio/shlink/issues/575) Added support to filter short URL lists by date ranges. - - * The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params. - * The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided. - -* [#338](https://github.com/shlinkio/shlink/issues/338) Added support to asynchronously notify external services via webhook, only when shlink is served with swoole. - - Configured webhooks will receive a POST request every time a URL receives a visit, including information about the short URL and the visit. - - The payload will look like this: - - ```json - { - "shortUrl": {}, - "visit": {} - } - ``` - - > The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io). - -### Changed -* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. -* [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%. -* [#557](https://github.com/shlinkio/shlink/issues/557) Added a few php.ini configs for development and production docker images. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#570](https://github.com/shlinkio/shlink/issues/570) Fixed shlink version generated for docker images when building from `develop` branch. - - -## [1.20.3] - 2019-12-23 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#585](https://github.com/shlinkio/shlink/issues/585) Fixed `PHP Fatal error: Uncaught Error: Class 'Shlinkio\Shlink\LocalLockFactory' not found` happening when running some CLI commands. - - -## [1.20.2] - 2019-12-06 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#561](https://github.com/shlinkio/shlink/issues/561) Fixed `db:migrate` command failing because yaml extension is not installed, which makes config file not to be readable. -* [#562](https://github.com/shlinkio/shlink/issues/562) Fixed internal server error being returned when renaming a tag to another tag's name. Now a meaningful API error with status 409 is returned. -* [#555](https://github.com/shlinkio/shlink/issues/555) Fixed internal server error being returned when invalid dates are provided for new short URLs. Now a 400 is returned, as intended. - - -## [1.20.1] - 2019-11-17 -### Added -* [#519](https://github.com/shlinkio/shlink/issues/519) Documented how to customize web workers and task workers for the docker image. - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#512](https://github.com/shlinkio/shlink/issues/512) Fixed query params not being properly forwarded from short URL to long one. -* [#540](https://github.com/shlinkio/shlink/issues/540) Fixed errors thrown when creating short URLs if the original URL has an internationalized domain name and URL validation is enabled. -* [#528](https://github.com/shlinkio/shlink/issues/528) Ensured `db:create` and `db:migrate` commands do not silently fail when run as part of `install` or `update`. -* [#518](https://github.com/shlinkio/shlink/issues/518) Fixed service which updates Geolite db file to use a local lock instead of a shared one, since every shlink instance holds its own db instance. - - -## [1.20.0] - 2019-11-02 -### Added -* [#491](https://github.com/shlinkio/shlink/issues/491) Added improved short code generation logic. - - Now, short codes are truly random, which removes the guessability factor existing in previous versions. - - Generated short codes have 5 characters, and shlink makes sure they keep unique, while making it backwards-compatible. - -* [#418](https://github.com/shlinkio/shlink/issues/418) and [#419](https://github.com/shlinkio/shlink/issues/419) Added support to redirect any 404 error to a custom URL. - - It was already possible to configure this but only for invalid short URLs. Shlink now also support configuring redirects for the base URL and any other kind of "not found" error. - - The three URLs can be different, and it is already possible to pass them to the docker image via configuration or env vars. - - The installer also asks for these two new configuration options. - -* [#497](https://github.com/shlinkio/shlink/issues/497) Officially added support for MariaDB. - -### Changed -* [#458](https://github.com/shlinkio/shlink/issues/458) Updated coding styles to use [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) v2.0.0. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#507](https://github.com/shlinkio/shlink/issues/507) Fixed error with too long original URLs by increasing size to the maximum value (2048) based on [the standard](https://stackoverflow.com/a/417184). -* [#502](https://github.com/shlinkio/shlink/issues/502) Fixed error when providing the port as part of the domain on short URLs. -* [#509](https://github.com/shlinkio/shlink/issues/509) Fixed error when trying to generate a QR code for a short URL which uses a custom domain. -* [#522](https://github.com/shlinkio/shlink/issues/522) Highly mitigated errors thrown when lots of short URLs are created concurrently including new and existing tags. - - -## [1.19.0] - 2019-10-05 -### Added -* [#482](https://github.com/shlinkio/shlink/issues/482) Added support to serve shlink under a sub path. - - The `router.base_path` config option can be defined now to set the base path from which shlink is served. - - ```php - return [ - 'router' => [ - 'base_path' => '/foo/bar', - ], - ]; - ``` - - This option will also be available on shlink-installer 1.3.0, so the installer will ask for it. It can also be provided for the docker image as the `BASE_PATH` env var. - -* [#479](https://github.com/shlinkio/shlink/issues/479) Added preliminary support for multiple domains. - - 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://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: - - * If the domain used for the request plus the short code/slug are found, the user is redirected to that long URL and the visit is tracked. - * If the domain is not known but the short code/slug is defined for default domain, the user is redirected there and the visit is tracked. - * In any other case, no redirection happens and no visit is tracked (if a fall back redirection is configured for not-found URLs, it will still happen). - -### Changed -* [#486](https://github.com/shlinkio/shlink/issues/486) Updated to [shlink-installer](https://github.com/shlinkio/shlink-installer) v2, which supports asking for base path in which shlink is served. - -### Deprecated -* *Nothing* - -### Removed -* [#435](https://github.com/shlinkio/shlink/issues/435) Removed translations for error pages. All error pages are in english now. - -### Fixed -* *Nothing* - - -## [1.18.1] - 2019-08-24 -### Added -* *Nothing* - -### Changed -* [#450](https://github.com/shlinkio/shlink/issues/450) Added PHP 7.4 to the build matrix, as an allowed-to-fail env. -* [#441](https://github.com/shlinkio/shlink/issues/441) and [#443](https://github.com/shlinkio/shlink/issues/443) Split some logic into independent modules. -* [#451](https://github.com/shlinkio/shlink/issues/451) Updated to infection 0.13. -* [#467](https://github.com/shlinkio/shlink/issues/467) Moved docker image config to main Shlink repo. - -### Deprecated -* [#428](https://github.com/shlinkio/shlink/issues/428) Deprecated preview-generation feature. It will keep working but it will be removed in Shlink v2.0.0 - -### Removed -* [#468](https://github.com/shlinkio/shlink/issues/468) Removed APCu extension from docker image. - -### Fixed -* [#449](https://github.com/shlinkio/shlink/issues/449) Fixed error when trying to save too big referrers on PostgreSQL. - - -## [1.18.0] - 2019-08-08 -### Added -* [#411](https://github.com/shlinkio/shlink/issues/411) Added new `meta` property on the `ShortUrl` REST API model. - - These endpoints are affected and include the new property when suitable: - - * `GET /short-urls` - List short URLs. - * `GET /short-urls/shorten` - Create a short URL (for integrations). - * `GET /short-urls/{shortCode}` - Get one short URL. - * `POST /short-urls` - Create short URL. - - The property includes the values `validSince`, `validUntil` and `maxVisits` in a single object. All of them are nullable. - - ```json - { - "validSince": "2016-01-01T00:00:00+02:00", - "validUntil": null, - "maxVisits": 100 - } - ``` - -* [#285](https://github.com/shlinkio/shlink/issues/285) Visit location resolution is now done asynchronously but in real time thanks to swoole task management. - - Now, when a short URL is visited, a task is enqueued to locate it. The user is immediately redirected to the long URL, and in the background, the visit is located, making stats to be available a couple of seconds after the visit without the requirement of cronjobs being run constantly. - - Sadly, this feature is not enabled when serving shlink via apache/nginx, where you should still rely on cronjobs. - -* [#384](https://github.com/shlinkio/shlink/issues/384) Improved how remote IP addresses are detected. - - This new set of headers is now also inspected looking for the IP address: - - * CF-Connecting-IP - * True-Client-IP - * X-Real-IP - -* [#440](https://github.com/shlinkio/shlink/pull/440) Created `db:create` command, which improves how the shlink database is created, with these benefits: - - * It sets up a lock which prevents the command to be run concurrently. - * It checks of the database does not exist, and creates it in that case. - * It checks if the database tables already exist, exiting gracefully in that case. - -* [#442](https://github.com/shlinkio/shlink/pull/442) Created `db:migrate` command, which improves doctrine's migrations command by generating a lock, preventing it to be run concurrently. - -### Changed -* [#430](https://github.com/shlinkio/shlink/issues/430) Updated to [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) 1.2.2 -* [#305](https://github.com/shlinkio/shlink/issues/305) Implemented changes which will allow Shlink to be truly clusterizable. -* [#262](https://github.com/shlinkio/shlink/issues/262) Increased mutation score to 75%. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#416](https://github.com/shlinkio/shlink/issues/416) Fixed error thrown when trying to locate visits after the GeoLite2 DB is downloaded for the first time. -* [#424](https://github.com/shlinkio/shlink/issues/424) Updated wkhtmltoimage to version 0.12.5 -* [#427](https://github.com/shlinkio/shlink/issues/427) and [#434](https://github.com/shlinkio/shlink/issues/434) Fixed shlink being unusable after a database error on swoole contexts. - - -## [1.17.0] - 2019-05-13 -### Added -* [#377](https://github.com/shlinkio/shlink/issues/377) Updated `visit:locate` command (formerly `visit:process`) to automatically update the GeoLite2 database if it is too old or it does not exist. - - This simplifies processing visits in a container-based infrastructure, since a fresh container is capable of getting an updated version of the file by itself. - - It also removes the need of asynchronously and programmatically updating the file, which deprecates the `visit:update-db` command. - -* [#373](https://github.com/shlinkio/shlink/issues/373) Added support for a simplified config. Specially useful to use with the docker container. - -### Changed -* [#56](https://github.com/shlinkio/shlink/issues/56) Simplified supported cache, requiring APCu always. - -### Deprecated -* [#406](https://github.com/shlinkio/shlink/issues/406) Deprecated `PUT /short-urls/{shortCode}` REST endpoint in favor of `PATCH /short-urls/{shortCode}`. - -### Removed -* [#385](https://github.com/shlinkio/shlink/issues/385) Dropped support for PHP 7.1 -* [#379](https://github.com/shlinkio/shlink/issues/379) Removed copyright from error templates. - -### Fixed -* *Nothing* - - -## [1.16.3] - 2019-03-30 -### Added -* *Nothing* - -### Changed -* [#153](https://github.com/shlinkio/shlink/issues/153) Updated to [doctrine/migrations](https://github.com/doctrine/migrations) version 2.0.0 -* [#376](https://github.com/shlinkio/shlink/issues/376) Allowed `visit:update-db` command to not return an error exit code even if download fails, by passing the `-i` flag. -* [#341](https://github.com/shlinkio/shlink/issues/341) Improved database tests so that they are executed against all supported database engines. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#382](https://github.com/shlinkio/shlink/issues/382) Fixed existing short URLs not properly checked when providing the `findIfExists` flag. - - -## [1.16.2] - 2019-03-05 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#368](https://github.com/shlinkio/shlink/issues/368) Fixed error produced when running a `SELECT COUNT(...)` with `ORDER BY` in PostgreSQL databases. - - -## [1.16.1] - 2019-02-26 -### Added -* *Nothing* - -### Changed -* [#363](https://github.com/shlinkio/shlink/issues/363) Updated to `shlinkio/php-coding-standard` version 1.1.0 - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#362](https://github.com/shlinkio/shlink/issues/362) Fixed all visits without an IP address being processed every time the `visit:process` command is executed. - - -## [1.16.0] - 2019-02-23 -### Added -* [#304](https://github.com/shlinkio/shlink/issues/304) Added health endpoint to check healthiness of the service. Useful in container-based infrastructures. - - Call [GET /rest/health] in order to get a response like this: - - ```http - HTTP/1.1 200 OK - Content-Type: application/health+json - Content-Length: 681 - - { - "status": "pass", - "version": "1.16.0", - "links": { - "about": "https://shlink.io", - "project": "https://github.com/shlinkio/shlink" - } - } - ``` - - The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/). - -* [#279](https://github.com/shlinkio/shlink/issues/279) Added new `findIfExists` flag to the `[POST /short-url]` REST endpoint and the `short-urls:generate` CLI command. It can be used to return existing short URLs when found, instead of creating new ones. - - Thanks to this flag you won't need to remember if you created a short URL for a long one. It will just create it if needed or return the existing one if possible. - - The behavior might be a little bit counterintuitive when combined with other params. This is how the endpoint behaves when providing this new flag: - - * Only the long URL is provided: It will return the newest match or create a new short URL if none is found. - * Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise. - * Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL. - -* [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service. - -### Changed -* [#342](https://github.com/shlinkio/shlink/issues/342) The installer no longer asks for a charset to be provided, and instead, it shuffles the base62 charset. -* [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated. -* [#330](https://github.com/shlinkio/shlink/issues/330) No longer allow failures on PHP 7.3 envs during project CI build. -* [#335](https://github.com/shlinkio/shlink/issues/335) Renamed functional test suite to database test suite, since that better describes what it actually does. -* [#346](https://github.com/shlinkio/shlink/issues/346) Extracted installer as an independent tool. -* [#261](https://github.com/shlinkio/shlink/issues/261) Increased mutation score to 70%. - -### Deprecated -* [#351](https://github.com/shlinkio/shlink/issues/351) Deprecated `config:generate-charset` and `config:generate-secret` CLI commands. - -### Removed -* *Nothing* - -### Fixed -* [#317](https://github.com/shlinkio/shlink/issues/317) Fixed error while trying to generate previews because of global config file being deleted by mistake by build script. -* [#307](https://github.com/shlinkio/shlink/issues/307) Fixed memory leak while trying to process huge amounts of visits due to the query not being properly paginated. - - -## [1.15.1] - 2018-12-16 -### Added -* [#162](https://github.com/shlinkio/shlink/issues/162) Added non-rest endpoints to swagger definition. - -### Changed -* [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image. -* [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2. -* [#321](https://github.com/shlinkio/shlink/issues/321) Extracted entities mappings from entities to external config files. -* [#308](https://github.com/shlinkio/shlink/issues/308) Automated docker image building. - -### Deprecated -* *Nothing* - -### Removed -* [#301](https://github.com/shlinkio/shlink/issues/301) Removed custom `AccessLogFactory` in favor of the implementation included in [zendframework/zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) v2.2.0 - -### Fixed -* [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser. -* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddleware` to be always piped. Now the check is not even made, which simplifies everything. - - -## [1.15.0] - 2018-12-02 -### Added -* [#208](https://github.com/shlinkio/shlink/issues/208) Added initial support to run shlink using [swoole](https://www.swoole.co.uk/), a non-blocking IO server which improves the performance of shlink from 4 to 10 times. - - Run shlink with `./vendor/bin/zend-expressive-swoole start` to start-up the service, which will be exposed in port `8080`. - - Adding the `-d` flag, it will be started as a background service. Then you can use the `./vendor/bin/zend-expressive-swoole stop` command in order to stop it. - -* [#266](https://github.com/shlinkio/shlink/issues/266) Added pagination to `GET /short-urls/{shortCode}/visits` endpoint. - - In order to make it backwards compatible, it keeps returning all visits by default, but it now allows to provide the `page` and `itemsPerPage` query parameters in order to configure the number of items to get. - -### Changed -* [#267](https://github.com/shlinkio/shlink/issues/267) API responses and the CLI interface is no longer translated and uses english always. Only not found error templates are still translated. -* [#289](https://github.com/shlinkio/shlink/issues/289) Extracted coding standard rules to a separated package. -* [#273](https://github.com/shlinkio/shlink/issues/273) Improved code coverage in repository classes. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#278](https://github.com/shlinkio/shlink/pull/278) Added missing `X-Api-Key` header to the list of valid cross domain headers. -* [#295](https://github.com/shlinkio/shlink/pull/295) Fixed custom slugs so that they are case sensitive and do not try to lowercase provided values. - - -## [1.14.1] - 2018-11-17 -### Added -* *Nothing* - -### Changed -* [#260](https://github.com/shlinkio/shlink/issues/260) Increased mutation score to 65%. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#271](https://github.com/shlinkio/shlink/issues/271) Fixed memory leak produced when processing high amounts of visits at the same time. -* [#272](https://github.com/shlinkio/shlink/issues/272) Fixed errors produced when trying to process visits multiple times in parallel, by using a lock which ensures only one instance is run at a time. - - -## [1.14.0] - 2018-11-16 -### Added -* [#236](https://github.com/shlinkio/shlink/issues/236) Added option to define a redirection to a custom URL when a user hits an invalid short URL. - - It only affects URLs matched as "short URL" where the short code is invalid, not any 404 that happens in the app. For example, a request to the path `/foo/bar` will keep returning a 404. - - This new option will be asked by the installer both for new shlink installations and for any previous shlink version which is updated. - -* [#189](https://github.com/shlinkio/shlink/issues/189) and [#240](https://github.com/shlinkio/shlink/issues/240) Added new [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/)-based geolocation service which is faster and more reliable than previous one. - - It does not have API limit problems, since it uses a local database file. - - Previous service is still used as a fallback in case GeoLite DB does not contain any IP address. - -### Changed -* [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase. -* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monolog's `PsrLogMessageProcessor`. -* [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported. -* [#196](https://github.com/shlinkio/shlink/issues/196) Reduced anemic model in entities, defining more expressive public APIs instead. -* [#249](https://github.com/shlinkio/shlink/issues/249) Added [functional-php](https://github.com/lstrojny/functional-php) to ease collections handling. -* [#253](https://github.com/shlinkio/shlink/issues/253) Increased `user_agent` column length in `visits` table to 512. -* [#256](https://github.com/shlinkio/shlink/issues/256) Updated to Infection v0.11. -* [#202](https://github.com/shlinkio/shlink/issues/202) Added missing response examples to OpenAPI docs. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#223](https://github.com/shlinkio/shlink/issues/223) Fixed PHPStan errors produced with symfony/console 4.1.5 - - -## [1.13.2] - 2018-10-18 -### Added -* [#233](https://github.com/shlinkio/shlink/issues/233) Added PHP 7.3 to build matrix allowing its failure. - -### Changed -* [#235](https://github.com/shlinkio/shlink/issues/235) Improved update instructions (thanks to [tivyhosting](https://github.com/tivyhosting)). - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#237](https://github.com/shlinkio/shlink/issues/233) Solved errors when trying to geo-locate `null` IP addresses. - - Also improved how visitor IP addresses are discovered, thanks to [akrabat/ip-address-middleware](https://github.com/akrabat/ip-address-middleware) package. - - -## [1.13.1] - 2018-10-16 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#231](https://github.com/shlinkio/shlink/issues/197) Fixed error when processing visits. - - -## [1.13.0] - 2018-10-06 -### Added -* [#197](https://github.com/shlinkio/shlink/issues/197) Added [cakephp/chronos](https://book.cakephp.org/3.0/en/chronos.html) library for date manipulations. -* [#214](https://github.com/shlinkio/shlink/issues/214) Improved build script, which allows builds to be done without "jumping" outside the project directory, and generates smaller dist files. - - It also allows automating the dist file generation in travis-ci builds. - -* [#207](https://github.com/shlinkio/shlink/issues/207) Added two new config options which are asked during installation process. The config options already existed in previous shlink version, but you had to manually set their values. - - These are the new options: - - * Visits threshold to allow short URLs to be deleted. - * Check the visits threshold when trying to delete a short URL via REST API. - -### Changed -* [#211](https://github.com/shlinkio/shlink/issues/211) Extracted installer to its own module, which will simplify moving it to a separated package in the future. -* [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) Renamed REST Action classes and CLI Command classes to use the concept of `ShortUrl` instead of the concept of `ShortCode` when referring to the entity, and left the `short code` concept to the identifier which is used as a unique code for a specific `Short URL`. -* [#181](https://github.com/shlinkio/shlink/issues/181) When importing the configuration from a previous shlink installation, it no longer asks to import every block. Instead, it is capable of detecting only new config options introduced in the new version, and ask only for those. - - If no new options are found and you have selected to import config, no further questions will be asked and shlink will just import the old config. - -### Deprecated -* [#205](https://github.com/shlinkio/shlink/issues/205) Deprecated `[POST /authenticate]` endpoint, and allowed any API request to be automatically authenticated using the `X-Api-Key` header with a valid API key. - - This effectively deprecates the `Authorization: Bearer ` authentication form, but it will keep working. - -* As of [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) REST urls have changed from `/short-codes/...` to `/short-urls/...`, and the command namespaces have changed from `short-code:...` to `short-url:...`. - - In both cases, backwards compatibility has been retained and the old ones are aliases for the new ones, but the old ones are considered deprecated. - -### Removed -* *Nothing* - -### Fixed -* [#203](https://github.com/shlinkio/shlink/issues/203) Fixed some warnings thrown while unzipping distributable files. -* [#206](https://github.com/shlinkio/shlink/issues/206) An error is now thrown during installation if any required param is left empty, making the installer display a message and ask again until a value is set. - - -## [1.12.0] - 2018-09-15 -### Added -* [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs. - - Due to the implicit danger of this operation, the deletion includes a safety check. URLs cannot be deleted if they have more than a specific amount of visits. - - The visits threshold is set to **15** by default and currently it has to be manually changed. In future versions the installation/update process will ask you about the value of the visits threshold. - - In order to change it, open the `config/autoload/delete_short_urls.global.php` file, which has this structure: - - ```php - return [ - - 'delete_short_urls' => [ - 'visits_threshold' => 15, - 'check_visits_threshold' => true, - ], - - ]; - ``` - - Properties are self explanatory. Change `check_visits_threshold` to `false` to completely disable this safety check, and change the value of `visits_threshold` to allow short URLs with a different number of visits to be deleted. - - Once changed, delete the `data/cache/app_config.php` file (if any) to let shlink know about the new values. - - This check is implicit for the API endpoint, but can be "disabled" for the CLI command, which will ask you when trying to delete a URL which has reached to threshold in order to force the deletion. - -* [#183](https://github.com/shlinkio/shlink/issues/183) and [#190](https://github.com/shlinkio/shlink/issues/190) Included important documentation improvements in the repository itself. You no longer need to go to the website in order to see how to install or use shlink. -* [#186](https://github.com/shlinkio/shlink/issues/186) Added a small robots.txt file that prevents 404 errors to be logged due to search engines trying to index the domain where shlink is located. Thanks to [@robwent](https://github.com/robwent) for the contribution. - -### Changed -* [#145](https://github.com/shlinkio/shlink/issues/145) Shlink now obfuscates IP addresses from visitors by replacing the latest octet by `0`, which does not affect geolocation and allows it to fulfil the GDPR. - - Other known services follow this same approach, like [Google Analytics](https://support.google.com/analytics/answer/2763052?hl=en) or [Matomo](https://matomo.org/docs/privacy/#step-1-automatically-anonymize-visitor-ips) - -* [#182](https://github.com/shlinkio/shlink/issues/182) The short URL creation API endpoints now return the same model used for lists and details endpoints. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL. - - -## [1.11.0] - 2018-08-13 -### Added -* [#170](https://github.com/shlinkio/shlink/issues/170) and [#171](https://github.com/shlinkio/shlink/issues/171) Updated `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints to return more meaningful information and make their response consistent. - - The short URLs are now represented by this object in both cases: - - ```json - { - "shortCode": "12Kb3", - "shortUrl": "https://s.test/12Kb3", - "longUrl": "https://shlink.io", - "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, - "tags": [ - "shlink" - ], - "originalUrl": "https://shlink.io" - } - ``` - - The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property. - -### Changed -* *Nothing* - -### Deprecated -* The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property. - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [1.10.2] - 2018-08-04 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time. -* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations. - - In order to fix that, just fill [this form](http://ip-api.com/docs/unban) including your server's IP address and your server should be unbanned. - - In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses. - - -## [1.10.1] - 2018-08-02 -### Added -* *Nothing* - -### Changed -* [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters. -* [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint. -* [#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://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. - - -## [1.10.0] - 2018-07-09 -### Added -* [#161](https://github.com/shlinkio/shlink/issues/161) AddED support for shlink to be run with [swoole](https://www.swoole.co.uk/) via [zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) package - -### Changed -* [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format -* [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10 - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [1.9.1] - 2018-06-18 -### Added -* [#155](https://github.com/shlinkio/shlink/issues/155) Improved the pagination object returned in lists, including more meaningful properties. - - * Old structure: - - ```json - { - "pagination": { - "currentPage": 1, - "pagesCount": 2 - } - } - ``` - - * New structure: - - ```json - { - "pagination": { - "currentPage": 2, - "pagesCount": 13, - "itemsPerPage": 10, - "itemsInCurrentPage": 10, - "totalItems": 126 - } - } - ``` - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm -* [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary - - -## [1.9.0] - 2018-05-07 -### Added -* [#147](https://github.com/shlinkio/shlink/issues/147) Allowed short URLs to be created on the fly using a single API request, including the API key in a query param. - - This eases integration with third party services. - - 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* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions - - -## [1.8.1] - 2018-04-07 -### Added -* *Nothing* - -### Changed -* [#141](https://github.com/shlinkio/shlink/issues/141) Removed workaround used in `PathVersionMiddleware`, since the bug in zend-stratigility has been fixed. - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#140](https://github.com/shlinkio/shlink/issues/140) Fixed warning thrown during installation while trying to include doctrine script - - -## [1.8.0] - 2018-03-29 -### 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://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 - -### Changed -* [#130](https://github.com/shlinkio/shlink/issues/130) Updated to Expressive 3 -* [#137](https://github.com/shlinkio/shlink/issues/137) Updated symfony components to v4 - -### Deprecated -* *Nothing* - -### Removed -* [#131](https://github.com/shlinkio/shlink/issues/131) Dropped support for PHP 7 - -### Fixed -* *Nothing* - - -## [1.7.2] - 2018-03-26 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#135](https://github.com/shlinkio/shlink/issues/135) Fixed `PathVersionMiddleware` being ignored when using expressive 2.2 - - -## [1.7.1] - 2018-03-21 -### Added -* *Nothing* - -### Changed -* [#128](https://github.com/shlinkio/shlink/issues/128) Upgraded to expressive 2.2 - - This will ease the upcoming update to expressive 3 - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#126](https://github.com/shlinkio/shlink/issues/126) Fixed `E_USER_DEPRECATED` errors triggered when using Expressive 2.2 - - -## [1.7.0] - 2018-01-21 -### Added -* [#88](https://github.com/shlinkio/shlink/issues/88) Allowed tracking of short URLs to be disabled by including a configurable query param -* [#108](https://github.com/shlinkio/shlink/issues/108) Allowed metadata to be defined when creating short codes - -### Changed -* [#113](https://github.com/shlinkio/shlink/issues/113) Updated CLI commands to use `SymfonyStyle` -* [#112](https://github.com/shlinkio/shlink/issues/112) Enabled Lazy loading in CLI commands -* [#117](https://github.com/shlinkio/shlink/issues/117) Every module which throws exceptions has now its own `ExceptionInterface` extending `Throwable` -* [#115](https://github.com/shlinkio/shlink/issues/115) Added phpstan to build matrix on PHP >=7.1 envs -* [#114](https://github.com/shlinkio/shlink/issues/114) Replaced [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) dev requirement by [symfony/dotenv](https://github.com/symfony/dotenv) - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [1.6.2] - 2017-10-25 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#109](https://github.com/shlinkio/shlink/issues/109) Fixed installation error due to typo in latest migration - - -## [1.6.1] - 2017-10-24 -### Added -* *Nothing* - -### Changed -* [#110](https://github.com/shlinkio/shlink/issues/110) Created `.gitattributes` file to define files to be excluded from distributable package - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [1.6.0] - 2017-10-23 -### Added -* [#44](https://github.com/shlinkio/shlink/issues/44) Now it is possible to set custom slugs for short URLs instead of using a generated short code -* [#47](https://github.com/shlinkio/shlink/issues/47) Allowed to limit short URLs availability by date range -* [#48](https://github.com/shlinkio/shlink/issues/48) Allowed to limit the number of visits to a short URL -* [#105](https://github.com/shlinkio/shlink/pull/105) Added option to enable/disable URL validation by response status code - -### Changed -* [#27](https://github.com/shlinkio/shlink/issues/27) Added repository functional tests with dbunit -* [#101](https://github.com/shlinkio/shlink/issues/101) Now specific actions just capture very specific exceptions, and let the `ErrorHandler` catch any other unhandled exception -* [#104](https://github.com/shlinkio/shlink/issues/104) Used different templates for *requested-short-code-does-not-exist* and *route-could-not-be-match* -* [#99](https://github.com/shlinkio/shlink/issues/99) Replaced usages of `AnnotatedFactory` by `ConfigAbstractFactory` -* [#100](https://github.com/shlinkio/shlink/issues/100) Updated templates engine. Replaced twig by plates -* [#102](https://github.com/shlinkio/shlink/issues/102) Improved coding standards strictness - -### Deprecated -* *Nothing* - -### Removed -* [#86](https://github.com/shlinkio/shlink/issues/86) Dropped support for PHP 5 - -### Fixed -* [#103](https://github.com/shlinkio/shlink/issues/103) `NotFoundDelegate` now returns proper content types based on accepted content - - -## [1.5.0] - 2017-07-16 -### Added -* [#95](https://github.com/shlinkio/shlink/issues/95) Added tags CRUD to CLI -* [#59](https://github.com/shlinkio/shlink/issues/59) Added tags CRUD to REST -* [#66](https://github.com/shlinkio/shlink/issues/66) Allowed certain information to be imported from and older shlink instance directory when updating - -### Changed -* [#96](https://github.com/shlinkio/shlink/issues/96) Added namespace to functions -* [#76](https://github.com/shlinkio/shlink/issues/76) Added response examples to swagger docs -* [#93](https://github.com/shlinkio/shlink/issues/93) Improved cross domain management by using the `ImplicitOptionsMiddleware` - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#92](https://github.com/shlinkio/shlink/issues/92) Fixed formatted dates, using an ISO compliant format - - -## [1.4.0] - 2017-03-25 -### Added -* *Nothing* - -### Changed -* [#89](https://github.com/shlinkio/shlink/issues/89) Updated to expressive 2 - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [1.3.1] - 2017-01-22 -### Added -* *Nothing* - -### Changed -* [#82](https://github.com/shlinkio/shlink/issues/82) Enabled `FastRoute` routes cache -* [#85](https://github.com/shlinkio/shlink/issues/85) Updated year in license file -* [#81](https://github.com/shlinkio/shlink/issues/81) Added docker containers config - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#83](https://github.com/shlinkio/shlink/issues/83) Fixed short codes list: search in tags when filtering by query string -* [#79](https://github.com/shlinkio/shlink/issues/79) Increased the number of followed redirects -* [#75](https://github.com/shlinkio/shlink/issues/75) Applied `PathVersionMiddleware` only to rest routes defining it by configuration instead of code -* [#77](https://github.com/shlinkio/shlink/issues/77) Allowed defining database server hostname and port - - -## [1.3.0] - 2016-10-23 -### Added -* [#67](https://github.com/shlinkio/shlink/issues/67) Allowed to order the short codes list -* [#60](https://github.com/shlinkio/shlink/issues/60) Accepted JSON requests in REST and used a body parser middleware to set the request's `parsedBody` -* [#72](https://github.com/shlinkio/shlink/issues/72) When listing API keys from CLI, use yellow color for enabled keys that have expired -* [#58](https://github.com/shlinkio/shlink/issues/58) Allowed to filter short URLs by tag -* [#69](https://github.com/shlinkio/shlink/issues/69) Allowed to filter short URLs by text query -* [#73](https://github.com/shlinkio/shlink/issues/73) Added tag-related endpoints to swagger file -* [#63](https://github.com/shlinkio/shlink/issues/63) Added path versioning to REST API routes - -### Changed -* [#71](https://github.com/shlinkio/shlink/issues/71) Separated swagger docs into multiple files - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* - - -## [1.2.2] - 2016-08-29 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* Fixed minor bugs on CORS requests - - -## [1.2.1] - 2016-08-21 -### Added -* *Nothing* - -### Changed -* *Nothing* - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API - - -## [1.2.0] - 2016-08-21 -### Added -* [#45](https://github.com/shlinkio/shlink/issues/45) Allowed to define tags on short codes, to improve filtering and classification -* [#7](https://github.com/shlinkio/shlink/issues/7) Added website previews while listing available URLs -* [#57](https://github.com/shlinkio/shlink/issues/57) Added database migrations system to improve updating between versions -* [#31](https://github.com/shlinkio/shlink/issues/31) Added support for other database management systems by improving the `EntityManager` factory -* [#51](https://github.com/shlinkio/shlink/issues/51) Generated build process to create app package and ease distribution -* [#38](https://github.com/shlinkio/shlink/issues/38) Defined installation script. It will request dynamic data on the fly so that there is no need to define env vars -* [#55](https://github.com/shlinkio/shlink/issues/55) Created update script which does not try to create a new database - -### Changed -* [#54](https://github.com/shlinkio/shlink/issues/54) Added cache namespace to prevent name collisions with other apps in the same environment -* [#29](https://github.com/shlinkio/shlink/issues/29) Used the [acelaya/ze-content-based-error-handler](https://github.com/acelaya/ze-content-based-error-handler) package instead of custom error handler implementation - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability -* [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments - - -## [1.1.0] - 2016-08-09 -### 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://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 -* [#13](https://github.com/shlinkio/shlink/issues/13) Improved REST authentication - -### Changed -* [#41](https://github.com/shlinkio/shlink/issues/41) Cached the "short code" => "URL" map to prevent extra DB hits -* [#39](https://github.com/shlinkio/shlink/issues/39) Changed copyright from "Alejandro Celaya" to "Shlink" in error pages -* [#42](https://github.com/shlinkio/shlink/issues/42) REST endpoints that need to find *something* now return a 404 when it is not found -* [#35](https://github.com/shlinkio/shlink/issues/35) Updated CLI commands to use the same PHP namespace as the one used for the command name - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* [#40](https://github.com/shlinkio/shlink/issues/40) Taken into account the `X-Forwarded-For` header in order to get the visitor information, in case the server is behind a load balancer or proxy - - -## [1.0.0] - 2016-08-01 -### Added -* [#33](https://github.com/shlinkio/shlink/issues/33) Created a command that generates a short code charset by randomizing the default one -* [#23](https://github.com/shlinkio/shlink/issues/23) Translated application literals -* [#21](https://github.com/shlinkio/shlink/issues/21) Allowed to filter visits by date range -* [#4](https://github.com/shlinkio/shlink/issues/4) Added installation steps -* [#12](https://github.com/shlinkio/shlink/issues/12) Improved code coverage - -### Changed -* [#15](https://github.com/shlinkio/shlink/issues/15) HTTP requests now return JSON/HTML responses for errors (4xx and 5xx) based on `Accept` header -* [#22](https://github.com/shlinkio/shlink/issues/22) Now visits locations data is saved on a `visit_locations` table -* [#20](https://github.com/shlinkio/shlink/issues/20) Injected cross domain headers in response only if the `Origin` header is present in the request -* [#11](https://github.com/shlinkio/shlink/issues/11) Separated code into multiple modules -* [#18](https://github.com/shlinkio/shlink/issues/18) Grouped routable middleware in an Action namespace -* [#6](https://github.com/shlinkio/shlink/issues/6) Project no longer depends on [zendframework/zend-expressive-helpers](https://github.com/zendframework/zend-expressive-helpers) package -* [#30](https://github.com/shlinkio/shlink/issues/30) Replaced the "services" first level config entry by "dependencies", in order to fulfill default Expressive naming -* [#25](https://github.com/shlinkio/shlink/issues/25) Replaced "Middleware" suffix on routable middlewares by "Action" -* [#19](https://github.com/shlinkio/shlink/issues/19) Changed the vendor and app namespace from `Acelaya\UrlShortener` to `Shlinkio\Shlink` - -### Deprecated -* *Nothing* - -### Removed -* [#36](https://github.com/shlinkio/shlink/issues/36) Removed hhvm from the CI matrix since it doesn't support array constants and will fail - -### Fixed -* [#24](https://github.com/shlinkio/shlink/issues/24) Prevented duplicated short codes errors because of the case insensitive behavior on MySQL - - -## [0.2.0] - 2016-08-01 -### Added -* [#8](https://github.com/shlinkio/shlink/issues/8) Created a REST API -* [#10](https://github.com/shlinkio/shlink/issues/10) Added more CLI functionality -* [#5](https://github.com/shlinkio/shlink/issues/5) Created a CHANGELOG file - -### Changed -* [#9](https://github.com/shlinkio/shlink/issues/9) Used [symfony/console](https://github.com/symfony/console) to dispatch console requests, instead of trying to integrate the process with expressive - -### Deprecated -* *Nothing* - -### Removed -* *Nothing* - -### Fixed -* *Nothing* diff --git a/composer.json b/composer.json index a8f52bd0..517caf0c 100644 --- a/composer.json +++ b/composer.json @@ -43,11 +43,11 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#3cb0845 as 6.1", + "shlinkio/shlink-common": "^6.1", "shlinkio/shlink-config": "^3.0", - "shlinkio/shlink-event-dispatcher": "dev-main#a2a5d6f as 4.1", + "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", - "shlinkio/shlink-installer": "dev-develop#164e23d as 9.1", + "shlinkio/shlink-installer": "^9.1", "shlinkio/shlink-ip-geolocation": "^4.0", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", diff --git a/docs/changelog-archive/CHANGELOG-1.x.md b/docs/changelog-archive/CHANGELOG-1.x.md new file mode 100644 index 00000000..33727999 --- /dev/null +++ b/docs/changelog-archive/CHANGELOG-1.x.md @@ -0,0 +1,1167 @@ +# CHANGELOG 1.x + +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). + +## [1.21.1] - 2020-01-02 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#596](https://github.com/shlinkio/shlink/issues/596) Fixed error when trying to download GeoLite2 database due to changes on how to get the database files. + + +## [1.21.0] - 2019-12-29 +### Added +* [#118](https://github.com/shlinkio/shlink/issues/118) API errors now implement the [problem details](https://tools.ietf.org/html/rfc7807) standard. + + In order to make it backwards compatible, two things have been done: + + * Both the old `error` and `message` properties have been kept on error response, containing the same values as the `type` and `detail` properties respectively. + * The API `v2` has been enabled. If an error occurs when calling the API with this version, the `error` and `message` properties will not be returned. + + > After Shlink v2 is released, both API versions will behave like API v2. + +* [#575](https://github.com/shlinkio/shlink/issues/575) Added support to filter short URL lists by date ranges. + + * The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params. + * The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided. + +* [#338](https://github.com/shlinkio/shlink/issues/338) Added support to asynchronously notify external services via webhook, only when shlink is served with swoole. + + Configured webhooks will receive a POST request every time a URL receives a visit, including information about the short URL and the visit. + + The payload will look like this: + + ```json + { + "shortUrl": {}, + "visit": {} + } + ``` + + > The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io). + +### Changed +* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. +* [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%. +* [#557](https://github.com/shlinkio/shlink/issues/557) Added a few php.ini configs for development and production docker images. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#570](https://github.com/shlinkio/shlink/issues/570) Fixed shlink version generated for docker images when building from `develop` branch. + + +## [1.20.3] - 2019-12-23 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#585](https://github.com/shlinkio/shlink/issues/585) Fixed `PHP Fatal error: Uncaught Error: Class 'Shlinkio\Shlink\LocalLockFactory' not found` happening when running some CLI commands. + + +## [1.20.2] - 2019-12-06 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#561](https://github.com/shlinkio/shlink/issues/561) Fixed `db:migrate` command failing because yaml extension is not installed, which makes config file not to be readable. +* [#562](https://github.com/shlinkio/shlink/issues/562) Fixed internal server error being returned when renaming a tag to another tag's name. Now a meaningful API error with status 409 is returned. +* [#555](https://github.com/shlinkio/shlink/issues/555) Fixed internal server error being returned when invalid dates are provided for new short URLs. Now a 400 is returned, as intended. + + +## [1.20.1] - 2019-11-17 +### Added +* [#519](https://github.com/shlinkio/shlink/issues/519) Documented how to customize web workers and task workers for the docker image. + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#512](https://github.com/shlinkio/shlink/issues/512) Fixed query params not being properly forwarded from short URL to long one. +* [#540](https://github.com/shlinkio/shlink/issues/540) Fixed errors thrown when creating short URLs if the original URL has an internationalized domain name and URL validation is enabled. +* [#528](https://github.com/shlinkio/shlink/issues/528) Ensured `db:create` and `db:migrate` commands do not silently fail when run as part of `install` or `update`. +* [#518](https://github.com/shlinkio/shlink/issues/518) Fixed service which updates Geolite db file to use a local lock instead of a shared one, since every shlink instance holds its own db instance. + + +## [1.20.0] - 2019-11-02 +### Added +* [#491](https://github.com/shlinkio/shlink/issues/491) Added improved short code generation logic. + + Now, short codes are truly random, which removes the guessability factor existing in previous versions. + + Generated short codes have 5 characters, and shlink makes sure they keep unique, while making it backwards-compatible. + +* [#418](https://github.com/shlinkio/shlink/issues/418) and [#419](https://github.com/shlinkio/shlink/issues/419) Added support to redirect any 404 error to a custom URL. + + It was already possible to configure this but only for invalid short URLs. Shlink now also support configuring redirects for the base URL and any other kind of "not found" error. + + The three URLs can be different, and it is already possible to pass them to the docker image via configuration or env vars. + + The installer also asks for these two new configuration options. + +* [#497](https://github.com/shlinkio/shlink/issues/497) Officially added support for MariaDB. + +### Changed +* [#458](https://github.com/shlinkio/shlink/issues/458) Updated coding styles to use [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) v2.0.0. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#507](https://github.com/shlinkio/shlink/issues/507) Fixed error with too long original URLs by increasing size to the maximum value (2048) based on [the standard](https://stackoverflow.com/a/417184). +* [#502](https://github.com/shlinkio/shlink/issues/502) Fixed error when providing the port as part of the domain on short URLs. +* [#509](https://github.com/shlinkio/shlink/issues/509) Fixed error when trying to generate a QR code for a short URL which uses a custom domain. +* [#522](https://github.com/shlinkio/shlink/issues/522) Highly mitigated errors thrown when lots of short URLs are created concurrently including new and existing tags. + + +## [1.19.0] - 2019-10-05 +### Added +* [#482](https://github.com/shlinkio/shlink/issues/482) Added support to serve shlink under a sub path. + + The `router.base_path` config option can be defined now to set the base path from which shlink is served. + + ```php + return [ + 'router' => [ + 'base_path' => '/foo/bar', + ], + ]; + ``` + + This option will also be available on shlink-installer 1.3.0, so the installer will ask for it. It can also be provided for the docker image as the `BASE_PATH` env var. + +* [#479](https://github.com/shlinkio/shlink/issues/479) Added preliminary support for multiple domains. + + 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://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: + + * If the domain used for the request plus the short code/slug are found, the user is redirected to that long URL and the visit is tracked. + * If the domain is not known but the short code/slug is defined for default domain, the user is redirected there and the visit is tracked. + * In any other case, no redirection happens and no visit is tracked (if a fall back redirection is configured for not-found URLs, it will still happen). + +### Changed +* [#486](https://github.com/shlinkio/shlink/issues/486) Updated to [shlink-installer](https://github.com/shlinkio/shlink-installer) v2, which supports asking for base path in which shlink is served. + +### Deprecated +* *Nothing* + +### Removed +* [#435](https://github.com/shlinkio/shlink/issues/435) Removed translations for error pages. All error pages are in english now. + +### Fixed +* *Nothing* + + +## [1.18.1] - 2019-08-24 +### Added +* *Nothing* + +### Changed +* [#450](https://github.com/shlinkio/shlink/issues/450) Added PHP 7.4 to the build matrix, as an allowed-to-fail env. +* [#441](https://github.com/shlinkio/shlink/issues/441) and [#443](https://github.com/shlinkio/shlink/issues/443) Split some logic into independent modules. +* [#451](https://github.com/shlinkio/shlink/issues/451) Updated to infection 0.13. +* [#467](https://github.com/shlinkio/shlink/issues/467) Moved docker image config to main Shlink repo. + +### Deprecated +* [#428](https://github.com/shlinkio/shlink/issues/428) Deprecated preview-generation feature. It will keep working but it will be removed in Shlink v2.0.0 + +### Removed +* [#468](https://github.com/shlinkio/shlink/issues/468) Removed APCu extension from docker image. + +### Fixed +* [#449](https://github.com/shlinkio/shlink/issues/449) Fixed error when trying to save too big referrers on PostgreSQL. + + +## [1.18.0] - 2019-08-08 +### Added +* [#411](https://github.com/shlinkio/shlink/issues/411) Added new `meta` property on the `ShortUrl` REST API model. + + These endpoints are affected and include the new property when suitable: + + * `GET /short-urls` - List short URLs. + * `GET /short-urls/shorten` - Create a short URL (for integrations). + * `GET /short-urls/{shortCode}` - Get one short URL. + * `POST /short-urls` - Create short URL. + + The property includes the values `validSince`, `validUntil` and `maxVisits` in a single object. All of them are nullable. + + ```json + { + "validSince": "2016-01-01T00:00:00+02:00", + "validUntil": null, + "maxVisits": 100 + } + ``` + +* [#285](https://github.com/shlinkio/shlink/issues/285) Visit location resolution is now done asynchronously but in real time thanks to swoole task management. + + Now, when a short URL is visited, a task is enqueued to locate it. The user is immediately redirected to the long URL, and in the background, the visit is located, making stats to be available a couple of seconds after the visit without the requirement of cronjobs being run constantly. + + Sadly, this feature is not enabled when serving shlink via apache/nginx, where you should still rely on cronjobs. + +* [#384](https://github.com/shlinkio/shlink/issues/384) Improved how remote IP addresses are detected. + + This new set of headers is now also inspected looking for the IP address: + + * CF-Connecting-IP + * True-Client-IP + * X-Real-IP + +* [#440](https://github.com/shlinkio/shlink/pull/440) Created `db:create` command, which improves how the shlink database is created, with these benefits: + + * It sets up a lock which prevents the command to be run concurrently. + * It checks of the database does not exist, and creates it in that case. + * It checks if the database tables already exist, exiting gracefully in that case. + +* [#442](https://github.com/shlinkio/shlink/pull/442) Created `db:migrate` command, which improves doctrine's migrations command by generating a lock, preventing it to be run concurrently. + +### Changed +* [#430](https://github.com/shlinkio/shlink/issues/430) Updated to [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) 1.2.2 +* [#305](https://github.com/shlinkio/shlink/issues/305) Implemented changes which will allow Shlink to be truly clusterizable. +* [#262](https://github.com/shlinkio/shlink/issues/262) Increased mutation score to 75%. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#416](https://github.com/shlinkio/shlink/issues/416) Fixed error thrown when trying to locate visits after the GeoLite2 DB is downloaded for the first time. +* [#424](https://github.com/shlinkio/shlink/issues/424) Updated wkhtmltoimage to version 0.12.5 +* [#427](https://github.com/shlinkio/shlink/issues/427) and [#434](https://github.com/shlinkio/shlink/issues/434) Fixed shlink being unusable after a database error on swoole contexts. + + +## [1.17.0] - 2019-05-13 +### Added +* [#377](https://github.com/shlinkio/shlink/issues/377) Updated `visit:locate` command (formerly `visit:process`) to automatically update the GeoLite2 database if it is too old or it does not exist. + + This simplifies processing visits in a container-based infrastructure, since a fresh container is capable of getting an updated version of the file by itself. + + It also removes the need of asynchronously and programmatically updating the file, which deprecates the `visit:update-db` command. + +* [#373](https://github.com/shlinkio/shlink/issues/373) Added support for a simplified config. Specially useful to use with the docker container. + +### Changed +* [#56](https://github.com/shlinkio/shlink/issues/56) Simplified supported cache, requiring APCu always. + +### Deprecated +* [#406](https://github.com/shlinkio/shlink/issues/406) Deprecated `PUT /short-urls/{shortCode}` REST endpoint in favor of `PATCH /short-urls/{shortCode}`. + +### Removed +* [#385](https://github.com/shlinkio/shlink/issues/385) Dropped support for PHP 7.1 +* [#379](https://github.com/shlinkio/shlink/issues/379) Removed copyright from error templates. + +### Fixed +* *Nothing* + + +## [1.16.3] - 2019-03-30 +### Added +* *Nothing* + +### Changed +* [#153](https://github.com/shlinkio/shlink/issues/153) Updated to [doctrine/migrations](https://github.com/doctrine/migrations) version 2.0.0 +* [#376](https://github.com/shlinkio/shlink/issues/376) Allowed `visit:update-db` command to not return an error exit code even if download fails, by passing the `-i` flag. +* [#341](https://github.com/shlinkio/shlink/issues/341) Improved database tests so that they are executed against all supported database engines. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#382](https://github.com/shlinkio/shlink/issues/382) Fixed existing short URLs not properly checked when providing the `findIfExists` flag. + + +## [1.16.2] - 2019-03-05 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#368](https://github.com/shlinkio/shlink/issues/368) Fixed error produced when running a `SELECT COUNT(...)` with `ORDER BY` in PostgreSQL databases. + + +## [1.16.1] - 2019-02-26 +### Added +* *Nothing* + +### Changed +* [#363](https://github.com/shlinkio/shlink/issues/363) Updated to `shlinkio/php-coding-standard` version 1.1.0 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#362](https://github.com/shlinkio/shlink/issues/362) Fixed all visits without an IP address being processed every time the `visit:process` command is executed. + + +## [1.16.0] - 2019-02-23 +### Added +* [#304](https://github.com/shlinkio/shlink/issues/304) Added health endpoint to check healthiness of the service. Useful in container-based infrastructures. + + Call [GET /rest/health] in order to get a response like this: + + ```http + HTTP/1.1 200 OK + Content-Type: application/health+json + Content-Length: 681 + + { + "status": "pass", + "version": "1.16.0", + "links": { + "about": "https://shlink.io", + "project": "https://github.com/shlinkio/shlink" + } + } + ``` + + The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/). + +* [#279](https://github.com/shlinkio/shlink/issues/279) Added new `findIfExists` flag to the `[POST /short-url]` REST endpoint and the `short-urls:generate` CLI command. It can be used to return existing short URLs when found, instead of creating new ones. + + Thanks to this flag you won't need to remember if you created a short URL for a long one. It will just create it if needed or return the existing one if possible. + + The behavior might be a little bit counterintuitive when combined with other params. This is how the endpoint behaves when providing this new flag: + + * Only the long URL is provided: It will return the newest match or create a new short URL if none is found. + * Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise. + * Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL. + +* [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service. + +### Changed +* [#342](https://github.com/shlinkio/shlink/issues/342) The installer no longer asks for a charset to be provided, and instead, it shuffles the base62 charset. +* [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated. +* [#330](https://github.com/shlinkio/shlink/issues/330) No longer allow failures on PHP 7.3 envs during project CI build. +* [#335](https://github.com/shlinkio/shlink/issues/335) Renamed functional test suite to database test suite, since that better describes what it actually does. +* [#346](https://github.com/shlinkio/shlink/issues/346) Extracted installer as an independent tool. +* [#261](https://github.com/shlinkio/shlink/issues/261) Increased mutation score to 70%. + +### Deprecated +* [#351](https://github.com/shlinkio/shlink/issues/351) Deprecated `config:generate-charset` and `config:generate-secret` CLI commands. + +### Removed +* *Nothing* + +### Fixed +* [#317](https://github.com/shlinkio/shlink/issues/317) Fixed error while trying to generate previews because of global config file being deleted by mistake by build script. +* [#307](https://github.com/shlinkio/shlink/issues/307) Fixed memory leak while trying to process huge amounts of visits due to the query not being properly paginated. + + +## [1.15.1] - 2018-12-16 +### Added +* [#162](https://github.com/shlinkio/shlink/issues/162) Added non-rest endpoints to swagger definition. + +### Changed +* [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image. +* [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2. +* [#321](https://github.com/shlinkio/shlink/issues/321) Extracted entities mappings from entities to external config files. +* [#308](https://github.com/shlinkio/shlink/issues/308) Automated docker image building. + +### Deprecated +* *Nothing* + +### Removed +* [#301](https://github.com/shlinkio/shlink/issues/301) Removed custom `AccessLogFactory` in favor of the implementation included in [zendframework/zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) v2.2.0 + +### Fixed +* [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser. +* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddleware` to be always piped. Now the check is not even made, which simplifies everything. + + +## [1.15.0] - 2018-12-02 +### Added +* [#208](https://github.com/shlinkio/shlink/issues/208) Added initial support to run shlink using [swoole](https://www.swoole.co.uk/), a non-blocking IO server which improves the performance of shlink from 4 to 10 times. + + Run shlink with `./vendor/bin/zend-expressive-swoole start` to start-up the service, which will be exposed in port `8080`. + + Adding the `-d` flag, it will be started as a background service. Then you can use the `./vendor/bin/zend-expressive-swoole stop` command in order to stop it. + +* [#266](https://github.com/shlinkio/shlink/issues/266) Added pagination to `GET /short-urls/{shortCode}/visits` endpoint. + + In order to make it backwards compatible, it keeps returning all visits by default, but it now allows to provide the `page` and `itemsPerPage` query parameters in order to configure the number of items to get. + +### Changed +* [#267](https://github.com/shlinkio/shlink/issues/267) API responses and the CLI interface is no longer translated and uses english always. Only not found error templates are still translated. +* [#289](https://github.com/shlinkio/shlink/issues/289) Extracted coding standard rules to a separated package. +* [#273](https://github.com/shlinkio/shlink/issues/273) Improved code coverage in repository classes. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#278](https://github.com/shlinkio/shlink/pull/278) Added missing `X-Api-Key` header to the list of valid cross domain headers. +* [#295](https://github.com/shlinkio/shlink/pull/295) Fixed custom slugs so that they are case sensitive and do not try to lowercase provided values. + + +## [1.14.1] - 2018-11-17 +### Added +* *Nothing* + +### Changed +* [#260](https://github.com/shlinkio/shlink/issues/260) Increased mutation score to 65%. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#271](https://github.com/shlinkio/shlink/issues/271) Fixed memory leak produced when processing high amounts of visits at the same time. +* [#272](https://github.com/shlinkio/shlink/issues/272) Fixed errors produced when trying to process visits multiple times in parallel, by using a lock which ensures only one instance is run at a time. + + +## [1.14.0] - 2018-11-16 +### Added +* [#236](https://github.com/shlinkio/shlink/issues/236) Added option to define a redirection to a custom URL when a user hits an invalid short URL. + + It only affects URLs matched as "short URL" where the short code is invalid, not any 404 that happens in the app. For example, a request to the path `/foo/bar` will keep returning a 404. + + This new option will be asked by the installer both for new shlink installations and for any previous shlink version which is updated. + +* [#189](https://github.com/shlinkio/shlink/issues/189) and [#240](https://github.com/shlinkio/shlink/issues/240) Added new [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/)-based geolocation service which is faster and more reliable than previous one. + + It does not have API limit problems, since it uses a local database file. + + Previous service is still used as a fallback in case GeoLite DB does not contain any IP address. + +### Changed +* [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase. +* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monolog's `PsrLogMessageProcessor`. +* [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported. +* [#196](https://github.com/shlinkio/shlink/issues/196) Reduced anemic model in entities, defining more expressive public APIs instead. +* [#249](https://github.com/shlinkio/shlink/issues/249) Added [functional-php](https://github.com/lstrojny/functional-php) to ease collections handling. +* [#253](https://github.com/shlinkio/shlink/issues/253) Increased `user_agent` column length in `visits` table to 512. +* [#256](https://github.com/shlinkio/shlink/issues/256) Updated to Infection v0.11. +* [#202](https://github.com/shlinkio/shlink/issues/202) Added missing response examples to OpenAPI docs. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#223](https://github.com/shlinkio/shlink/issues/223) Fixed PHPStan errors produced with symfony/console 4.1.5 + + +## [1.13.2] - 2018-10-18 +### Added +* [#233](https://github.com/shlinkio/shlink/issues/233) Added PHP 7.3 to build matrix allowing its failure. + +### Changed +* [#235](https://github.com/shlinkio/shlink/issues/235) Improved update instructions (thanks to [tivyhosting](https://github.com/tivyhosting)). + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#237](https://github.com/shlinkio/shlink/issues/233) Solved errors when trying to geo-locate `null` IP addresses. + + Also improved how visitor IP addresses are discovered, thanks to [akrabat/ip-address-middleware](https://github.com/akrabat/ip-address-middleware) package. + + +## [1.13.1] - 2018-10-16 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#231](https://github.com/shlinkio/shlink/issues/197) Fixed error when processing visits. + + +## [1.13.0] - 2018-10-06 +### Added +* [#197](https://github.com/shlinkio/shlink/issues/197) Added [cakephp/chronos](https://book.cakephp.org/3.0/en/chronos.html) library for date manipulations. +* [#214](https://github.com/shlinkio/shlink/issues/214) Improved build script, which allows builds to be done without "jumping" outside the project directory, and generates smaller dist files. + + It also allows automating the dist file generation in travis-ci builds. + +* [#207](https://github.com/shlinkio/shlink/issues/207) Added two new config options which are asked during installation process. The config options already existed in previous shlink version, but you had to manually set their values. + + These are the new options: + + * Visits threshold to allow short URLs to be deleted. + * Check the visits threshold when trying to delete a short URL via REST API. + +### Changed +* [#211](https://github.com/shlinkio/shlink/issues/211) Extracted installer to its own module, which will simplify moving it to a separated package in the future. +* [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) Renamed REST Action classes and CLI Command classes to use the concept of `ShortUrl` instead of the concept of `ShortCode` when referring to the entity, and left the `short code` concept to the identifier which is used as a unique code for a specific `Short URL`. +* [#181](https://github.com/shlinkio/shlink/issues/181) When importing the configuration from a previous shlink installation, it no longer asks to import every block. Instead, it is capable of detecting only new config options introduced in the new version, and ask only for those. + + If no new options are found and you have selected to import config, no further questions will be asked and shlink will just import the old config. + +### Deprecated +* [#205](https://github.com/shlinkio/shlink/issues/205) Deprecated `[POST /authenticate]` endpoint, and allowed any API request to be automatically authenticated using the `X-Api-Key` header with a valid API key. + + This effectively deprecates the `Authorization: Bearer ` authentication form, but it will keep working. + +* As of [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) REST urls have changed from `/short-codes/...` to `/short-urls/...`, and the command namespaces have changed from `short-code:...` to `short-url:...`. + + In both cases, backwards compatibility has been retained and the old ones are aliases for the new ones, but the old ones are considered deprecated. + +### Removed +* *Nothing* + +### Fixed +* [#203](https://github.com/shlinkio/shlink/issues/203) Fixed some warnings thrown while unzipping distributable files. +* [#206](https://github.com/shlinkio/shlink/issues/206) An error is now thrown during installation if any required param is left empty, making the installer display a message and ask again until a value is set. + + +## [1.12.0] - 2018-09-15 +### Added +* [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs. + + Due to the implicit danger of this operation, the deletion includes a safety check. URLs cannot be deleted if they have more than a specific amount of visits. + + The visits threshold is set to **15** by default and currently it has to be manually changed. In future versions the installation/update process will ask you about the value of the visits threshold. + + In order to change it, open the `config/autoload/delete_short_urls.global.php` file, which has this structure: + + ```php + return [ + + 'delete_short_urls' => [ + 'visits_threshold' => 15, + 'check_visits_threshold' => true, + ], + + ]; + ``` + + Properties are self explanatory. Change `check_visits_threshold` to `false` to completely disable this safety check, and change the value of `visits_threshold` to allow short URLs with a different number of visits to be deleted. + + Once changed, delete the `data/cache/app_config.php` file (if any) to let shlink know about the new values. + + This check is implicit for the API endpoint, but can be "disabled" for the CLI command, which will ask you when trying to delete a URL which has reached to threshold in order to force the deletion. + +* [#183](https://github.com/shlinkio/shlink/issues/183) and [#190](https://github.com/shlinkio/shlink/issues/190) Included important documentation improvements in the repository itself. You no longer need to go to the website in order to see how to install or use shlink. +* [#186](https://github.com/shlinkio/shlink/issues/186) Added a small robots.txt file that prevents 404 errors to be logged due to search engines trying to index the domain where shlink is located. Thanks to [@robwent](https://github.com/robwent) for the contribution. + +### Changed +* [#145](https://github.com/shlinkio/shlink/issues/145) Shlink now obfuscates IP addresses from visitors by replacing the latest octet by `0`, which does not affect geolocation and allows it to fulfil the GDPR. + + Other known services follow this same approach, like [Google Analytics](https://support.google.com/analytics/answer/2763052?hl=en) or [Matomo](https://matomo.org/docs/privacy/#step-1-automatically-anonymize-visitor-ips) + +* [#182](https://github.com/shlinkio/shlink/issues/182) The short URL creation API endpoints now return the same model used for lists and details endpoints. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL. + + +## [1.11.0] - 2018-08-13 +### Added +* [#170](https://github.com/shlinkio/shlink/issues/170) and [#171](https://github.com/shlinkio/shlink/issues/171) Updated `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints to return more meaningful information and make their response consistent. + + The short URLs are now represented by this object in both cases: + + ```json + { + "shortCode": "12Kb3", + "shortUrl": "https://s.test/12Kb3", + "longUrl": "https://shlink.io", + "dateCreated": "2016-05-01T20:34:16+02:00", + "visitsCount": 1029, + "tags": [ + "shlink" + ], + "originalUrl": "https://shlink.io" + } + ``` + + The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property. + +### Changed +* *Nothing* + +### Deprecated +* The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property. + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [1.10.2] - 2018-08-04 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time. +* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations. + + In order to fix that, just fill [this form](http://ip-api.com/docs/unban) including your server's IP address and your server should be unbanned. + + In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses. + + +## [1.10.1] - 2018-08-02 +### Added +* *Nothing* + +### Changed +* [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters. +* [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint. +* [#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://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. + + +## [1.10.0] - 2018-07-09 +### Added +* [#161](https://github.com/shlinkio/shlink/issues/161) AddED support for shlink to be run with [swoole](https://www.swoole.co.uk/) via [zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) package + +### Changed +* [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format +* [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [1.9.1] - 2018-06-18 +### Added +* [#155](https://github.com/shlinkio/shlink/issues/155) Improved the pagination object returned in lists, including more meaningful properties. + + * Old structure: + + ```json + { + "pagination": { + "currentPage": 1, + "pagesCount": 2 + } + } + ``` + + * New structure: + + ```json + { + "pagination": { + "currentPage": 2, + "pagesCount": 13, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 126 + } + } + ``` + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm +* [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary + + +## [1.9.0] - 2018-05-07 +### Added +* [#147](https://github.com/shlinkio/shlink/issues/147) Allowed short URLs to be created on the fly using a single API request, including the API key in a query param. + + This eases integration with third party services. + + 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* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions + + +## [1.8.1] - 2018-04-07 +### Added +* *Nothing* + +### Changed +* [#141](https://github.com/shlinkio/shlink/issues/141) Removed workaround used in `PathVersionMiddleware`, since the bug in zend-stratigility has been fixed. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#140](https://github.com/shlinkio/shlink/issues/140) Fixed warning thrown during installation while trying to include doctrine script + + +## [1.8.0] - 2018-03-29 +### 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://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 + +### Changed +* [#130](https://github.com/shlinkio/shlink/issues/130) Updated to Expressive 3 +* [#137](https://github.com/shlinkio/shlink/issues/137) Updated symfony components to v4 + +### Deprecated +* *Nothing* + +### Removed +* [#131](https://github.com/shlinkio/shlink/issues/131) Dropped support for PHP 7 + +### Fixed +* *Nothing* + + +## [1.7.2] - 2018-03-26 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#135](https://github.com/shlinkio/shlink/issues/135) Fixed `PathVersionMiddleware` being ignored when using expressive 2.2 + + +## [1.7.1] - 2018-03-21 +### Added +* *Nothing* + +### Changed +* [#128](https://github.com/shlinkio/shlink/issues/128) Upgraded to expressive 2.2 + + This will ease the upcoming update to expressive 3 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#126](https://github.com/shlinkio/shlink/issues/126) Fixed `E_USER_DEPRECATED` errors triggered when using Expressive 2.2 + + +## [1.7.0] - 2018-01-21 +### Added +* [#88](https://github.com/shlinkio/shlink/issues/88) Allowed tracking of short URLs to be disabled by including a configurable query param +* [#108](https://github.com/shlinkio/shlink/issues/108) Allowed metadata to be defined when creating short codes + +### Changed +* [#113](https://github.com/shlinkio/shlink/issues/113) Updated CLI commands to use `SymfonyStyle` +* [#112](https://github.com/shlinkio/shlink/issues/112) Enabled Lazy loading in CLI commands +* [#117](https://github.com/shlinkio/shlink/issues/117) Every module which throws exceptions has now its own `ExceptionInterface` extending `Throwable` +* [#115](https://github.com/shlinkio/shlink/issues/115) Added phpstan to build matrix on PHP >=7.1 envs +* [#114](https://github.com/shlinkio/shlink/issues/114) Replaced [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) dev requirement by [symfony/dotenv](https://github.com/symfony/dotenv) + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [1.6.2] - 2017-10-25 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#109](https://github.com/shlinkio/shlink/issues/109) Fixed installation error due to typo in latest migration + + +## [1.6.1] - 2017-10-24 +### Added +* *Nothing* + +### Changed +* [#110](https://github.com/shlinkio/shlink/issues/110) Created `.gitattributes` file to define files to be excluded from distributable package + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [1.6.0] - 2017-10-23 +### Added +* [#44](https://github.com/shlinkio/shlink/issues/44) Now it is possible to set custom slugs for short URLs instead of using a generated short code +* [#47](https://github.com/shlinkio/shlink/issues/47) Allowed to limit short URLs availability by date range +* [#48](https://github.com/shlinkio/shlink/issues/48) Allowed to limit the number of visits to a short URL +* [#105](https://github.com/shlinkio/shlink/pull/105) Added option to enable/disable URL validation by response status code + +### Changed +* [#27](https://github.com/shlinkio/shlink/issues/27) Added repository functional tests with dbunit +* [#101](https://github.com/shlinkio/shlink/issues/101) Now specific actions just capture very specific exceptions, and let the `ErrorHandler` catch any other unhandled exception +* [#104](https://github.com/shlinkio/shlink/issues/104) Used different templates for *requested-short-code-does-not-exist* and *route-could-not-be-match* +* [#99](https://github.com/shlinkio/shlink/issues/99) Replaced usages of `AnnotatedFactory` by `ConfigAbstractFactory` +* [#100](https://github.com/shlinkio/shlink/issues/100) Updated templates engine. Replaced twig by plates +* [#102](https://github.com/shlinkio/shlink/issues/102) Improved coding standards strictness + +### Deprecated +* *Nothing* + +### Removed +* [#86](https://github.com/shlinkio/shlink/issues/86) Dropped support for PHP 5 + +### Fixed +* [#103](https://github.com/shlinkio/shlink/issues/103) `NotFoundDelegate` now returns proper content types based on accepted content + + +## [1.5.0] - 2017-07-16 +### Added +* [#95](https://github.com/shlinkio/shlink/issues/95) Added tags CRUD to CLI +* [#59](https://github.com/shlinkio/shlink/issues/59) Added tags CRUD to REST +* [#66](https://github.com/shlinkio/shlink/issues/66) Allowed certain information to be imported from and older shlink instance directory when updating + +### Changed +* [#96](https://github.com/shlinkio/shlink/issues/96) Added namespace to functions +* [#76](https://github.com/shlinkio/shlink/issues/76) Added response examples to swagger docs +* [#93](https://github.com/shlinkio/shlink/issues/93) Improved cross domain management by using the `ImplicitOptionsMiddleware` + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#92](https://github.com/shlinkio/shlink/issues/92) Fixed formatted dates, using an ISO compliant format + + +## [1.4.0] - 2017-03-25 +### Added +* *Nothing* + +### Changed +* [#89](https://github.com/shlinkio/shlink/issues/89) Updated to expressive 2 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [1.3.1] - 2017-01-22 +### Added +* *Nothing* + +### Changed +* [#82](https://github.com/shlinkio/shlink/issues/82) Enabled `FastRoute` routes cache +* [#85](https://github.com/shlinkio/shlink/issues/85) Updated year in license file +* [#81](https://github.com/shlinkio/shlink/issues/81) Added docker containers config + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#83](https://github.com/shlinkio/shlink/issues/83) Fixed short codes list: search in tags when filtering by query string +* [#79](https://github.com/shlinkio/shlink/issues/79) Increased the number of followed redirects +* [#75](https://github.com/shlinkio/shlink/issues/75) Applied `PathVersionMiddleware` only to rest routes defining it by configuration instead of code +* [#77](https://github.com/shlinkio/shlink/issues/77) Allowed defining database server hostname and port + + +## [1.3.0] - 2016-10-23 +### Added +* [#67](https://github.com/shlinkio/shlink/issues/67) Allowed to order the short codes list +* [#60](https://github.com/shlinkio/shlink/issues/60) Accepted JSON requests in REST and used a body parser middleware to set the request's `parsedBody` +* [#72](https://github.com/shlinkio/shlink/issues/72) When listing API keys from CLI, use yellow color for enabled keys that have expired +* [#58](https://github.com/shlinkio/shlink/issues/58) Allowed to filter short URLs by tag +* [#69](https://github.com/shlinkio/shlink/issues/69) Allowed to filter short URLs by text query +* [#73](https://github.com/shlinkio/shlink/issues/73) Added tag-related endpoints to swagger file +* [#63](https://github.com/shlinkio/shlink/issues/63) Added path versioning to REST API routes + +### Changed +* [#71](https://github.com/shlinkio/shlink/issues/71) Separated swagger docs into multiple files + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [1.2.2] - 2016-08-29 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* Fixed minor bugs on CORS requests + + +## [1.2.1] - 2016-08-21 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API + + +## [1.2.0] - 2016-08-21 +### Added +* [#45](https://github.com/shlinkio/shlink/issues/45) Allowed to define tags on short codes, to improve filtering and classification +* [#7](https://github.com/shlinkio/shlink/issues/7) Added website previews while listing available URLs +* [#57](https://github.com/shlinkio/shlink/issues/57) Added database migrations system to improve updating between versions +* [#31](https://github.com/shlinkio/shlink/issues/31) Added support for other database management systems by improving the `EntityManager` factory +* [#51](https://github.com/shlinkio/shlink/issues/51) Generated build process to create app package and ease distribution +* [#38](https://github.com/shlinkio/shlink/issues/38) Defined installation script. It will request dynamic data on the fly so that there is no need to define env vars +* [#55](https://github.com/shlinkio/shlink/issues/55) Created update script which does not try to create a new database + +### Changed +* [#54](https://github.com/shlinkio/shlink/issues/54) Added cache namespace to prevent name collisions with other apps in the same environment +* [#29](https://github.com/shlinkio/shlink/issues/29) Used the [acelaya/ze-content-based-error-handler](https://github.com/acelaya/ze-content-based-error-handler) package instead of custom error handler implementation + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability +* [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments + + +## [1.1.0] - 2016-08-09 +### 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://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 +* [#13](https://github.com/shlinkio/shlink/issues/13) Improved REST authentication + +### Changed +* [#41](https://github.com/shlinkio/shlink/issues/41) Cached the "short code" => "URL" map to prevent extra DB hits +* [#39](https://github.com/shlinkio/shlink/issues/39) Changed copyright from "Alejandro Celaya" to "Shlink" in error pages +* [#42](https://github.com/shlinkio/shlink/issues/42) REST endpoints that need to find *something* now return a 404 when it is not found +* [#35](https://github.com/shlinkio/shlink/issues/35) Updated CLI commands to use the same PHP namespace as the one used for the command name + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#40](https://github.com/shlinkio/shlink/issues/40) Taken into account the `X-Forwarded-For` header in order to get the visitor information, in case the server is behind a load balancer or proxy + + +## [1.0.0] - 2016-08-01 +### Added +* [#33](https://github.com/shlinkio/shlink/issues/33) Created a command that generates a short code charset by randomizing the default one +* [#23](https://github.com/shlinkio/shlink/issues/23) Translated application literals +* [#21](https://github.com/shlinkio/shlink/issues/21) Allowed to filter visits by date range +* [#4](https://github.com/shlinkio/shlink/issues/4) Added installation steps +* [#12](https://github.com/shlinkio/shlink/issues/12) Improved code coverage + +### Changed +* [#15](https://github.com/shlinkio/shlink/issues/15) HTTP requests now return JSON/HTML responses for errors (4xx and 5xx) based on `Accept` header +* [#22](https://github.com/shlinkio/shlink/issues/22) Now visits locations data is saved on a `visit_locations` table +* [#20](https://github.com/shlinkio/shlink/issues/20) Injected cross domain headers in response only if the `Origin` header is present in the request +* [#11](https://github.com/shlinkio/shlink/issues/11) Separated code into multiple modules +* [#18](https://github.com/shlinkio/shlink/issues/18) Grouped routable middleware in an Action namespace +* [#6](https://github.com/shlinkio/shlink/issues/6) Project no longer depends on [zendframework/zend-expressive-helpers](https://github.com/zendframework/zend-expressive-helpers) package +* [#30](https://github.com/shlinkio/shlink/issues/30) Replaced the "services" first level config entry by "dependencies", in order to fulfill default Expressive naming +* [#25](https://github.com/shlinkio/shlink/issues/25) Replaced "Middleware" suffix on routable middlewares by "Action" +* [#19](https://github.com/shlinkio/shlink/issues/19) Changed the vendor and app namespace from `Acelaya\UrlShortener` to `Shlinkio\Shlink` + +### Deprecated +* *Nothing* + +### Removed +* [#36](https://github.com/shlinkio/shlink/issues/36) Removed hhvm from the CI matrix since it doesn't support array constants and will fail + +### Fixed +* [#24](https://github.com/shlinkio/shlink/issues/24) Prevented duplicated short codes errors because of the case insensitive behavior on MySQL + + +## [0.2.0] - 2016-08-01 +### Added +* [#8](https://github.com/shlinkio/shlink/issues/8) Created a REST API +* [#10](https://github.com/shlinkio/shlink/issues/10) Added more CLI functionality +* [#5](https://github.com/shlinkio/shlink/issues/5) Created a CHANGELOG file + +### Changed +* [#9](https://github.com/shlinkio/shlink/issues/9) Used [symfony/console](https://github.com/symfony/console) to dispatch console requests, instead of trying to integrate the process with expressive + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* +* diff --git a/docs/changelog-archive/CHANGELOG-2.x.md b/docs/changelog-archive/CHANGELOG-2.x.md new file mode 100644 index 00000000..72bb6f5d --- /dev/null +++ b/docs/changelog-archive/CHANGELOG-2.x.md @@ -0,0 +1,912 @@ +# CHANGELOG 2.x + +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). + +## [2.10.3] - 2022-01-23 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1349](https://github.com/shlinkio/shlink/issues/1349) Fixed memory leak in cache implementation. + + +## [2.10.2] - 2022-01-07 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1293](https://github.com/shlinkio/shlink/issues/1293) Fixed error when trying to create/import short URLs with a too long title. +* [#1306](https://github.com/shlinkio/shlink/issues/1306) Ensured remote IP address is not logged when using swoole/openswoole. +* [#1308](https://github.com/shlinkio/shlink/issues/1308) Fixed memory leak when using redis due to the amount of non-expiring keys created by doctrine. Now they have a 24h expiration by default. + + +## [2.10.1] - 2021-12-21 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1285](https://github.com/shlinkio/shlink/issues/1285) Fixed error caused by database connections expiring after some hours of inactivity. +* [#1286](https://github.com/shlinkio/shlink/issues/1286) Fixed `x-request-id` header not being generated during non-rest requests. + + +## [2.10.0] - 2021-12-12 +### Added +* [#1163](https://github.com/shlinkio/shlink/issues/1163) Allowed setting not-found redirects for default domain in the same way it's done for any other domain. + + This implies a few non-breaking changes: + + * The domains list no longer has the values of `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` on the default domain redirects. + * The `GET /domains` endpoint includes a new `defaultRedirects` property in the response, with the default redirects set via config or env vars. + * The `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` env vars are now deprecated, and should be replaced by `DEFAULT_INVALID_SHORT_URL_REDIRECT`, `DEFAULT_REGULAR_404_REDIRECT` and `DEFAULT_BASE_URL_REDIRECT` respectively. Deprecated ones will continue to work until v3.0.0, where they will be removed. + +* [#868](https://github.com/shlinkio/shlink/issues/868) Added support to publish real-time updates in a RabbitMQ server. + + Shlink will create new exchanges and queues for every topic documented in the [Async API spec](https://api-spec.shlink.io/async-api/), meaning, you will have one queue for orphan visits, one for regular visits, and one queue for every short URL with its visits. + + The RabbitMQ server config can be provided via installer config options, or via environment variables. + +* [#1204](https://github.com/shlinkio/shlink/issues/1204) Added support for `openswoole` and migrated official docker image to `openswoole`. +* [#1242](https://github.com/shlinkio/shlink/issues/1242) Added support to import urls and visits from YOURLS. + + In order to do it, you need to first install this [dedicated plugin](https://slnk.to/yourls-import) in YOURLS, and then run the `short-url:import yourls` command, as with any other source. + +* [#1235](https://github.com/shlinkio/shlink/issues/1235) Added support to disable rounding QR codes block sizing via config option, env var or query param. +* [#1188](https://github.com/shlinkio/shlink/issues/1188) Added support for PHP 8.1. + + The official docker image has also been updated to use PHP 8.1 by default. + +### Changed +* [#844](https://github.com/shlinkio/shlink/issues/844) Added mutation checks to API tests. +* [#1218](https://github.com/shlinkio/shlink/issues/1218) Updated to symfony/mercure 0.6. +* [#1223](https://github.com/shlinkio/shlink/issues/1223) Updated to phpstan 1.0. +* [#1258](https://github.com/shlinkio/shlink/issues/1258) Updated to Symfony 6 components, except symfony/console. +* Added `domain` field to `DeleteShortUrlException` exception. + +### Deprecated +* [#1260](https://github.com/shlinkio/shlink/issues/1260) Deprecated `USE_HTTPS` env var that was added in previous release, in favor of the new `IS_HTTPS_ENABLED`. + + The old one proved to be confusing and misleading, making people think it was used to actually enable HTTPS transparently, instead of its actual purpose, which is just telling Shlink it is being served with HTTPS. + +### Removed +* *Nothing* + +### Fixed +* [#1206](https://github.com/shlinkio/shlink/issues/1206) Fixed debugging of the docker image, so that it does not run the commands with `-q` when the `SHELL_VERBOSITY` env var has been provided. +* [#1254](https://github.com/shlinkio/shlink/issues/1254) Fixed examples in swagger docs. + + +## [2.9.3] - 2021-11-15 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1232](https://github.com/shlinkio/shlink/issues/1232) Solved potential SQL injection by enforcing `doctrine/dbal` 3.1.4. + + +## [2.9.2] - 2021-10-23 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1210](https://github.com/shlinkio/shlink/issues/1210) Fixed real time updates not being notified due to an incorrect handling of db transactions on multi-process tasks. +* [#1211](https://github.com/shlinkio/shlink/issues/1211) Fixed `There is no active transaction` error when running migrations in MySQL/Mariadb after updating to doctrine-migrations 3.3. +* [#1197](https://github.com/shlinkio/shlink/issues/1197) Fixed amount of task workers provided via config option or env var not being validated to ensure enough workers to process all parallel tasks. + + +## [2.9.1] - 2021-10-11 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1201](https://github.com/shlinkio/shlink/issues/1201) Fixed crash when using the new `USE_HTTPS`, as it's boolean raw value was being used instead of resolving "https" or "http". + + +## [2.9.0] - 2021-10-10 +### Added +* [#1015](https://github.com/shlinkio/shlink/issues/1015) Shlink now accepts configuration via env vars even when not using docker. + + The config generated with the installing tool still has precedence over the env vars, so it cannot be combined. Either you use the tool, or use env vars. + +* [#1149](https://github.com/shlinkio/shlink/issues/1149) Allowed to set custom defaults for the QR codes. +* [#1112](https://github.com/shlinkio/shlink/issues/1112) Added new option to define if the query string should be forwarded on a per-short URL basis. + + The new `forwardQuery=true|false` param can be provided during short URL creation or edition, via REST API or CLI command, allowing to override the default behavior which makes the query string to always be forwarded. + +* [#1105](https://github.com/shlinkio/shlink/issues/1105) Added support to define placeholders on not-found redirects, so that the redirected URL receives the originally visited path and/or domain. + + Currently, `{DOMAIN}` and `{ORIGINAL_PATH}` placeholders are supported, and they can be used both in the redirected URL's path or query. + + When they are used in the query, the values are URL encoded. + +* [#1119](https://github.com/shlinkio/shlink/issues/1119) Added support to provide redis sentinel when using redis cache. +* [#1016](https://github.com/shlinkio/shlink/issues/1016) Added new option to send orphan visits to webhooks, via `NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS` env var or installer tool. + + The option is disabled by default, as the payload is backwards incompatible. You will need to adapt your webhooks to treat the `shortUrl` property as optional before enabling this option. + +* [#1104](https://github.com/shlinkio/shlink/issues/1104) Added ability to disable tracking based on IP addresses. + + IP addresses can be provided in the form of fixed addresses, CIDR blocks, or wildcard patterns (192.168.*.*). + +### Changed +* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`. +* [#1157](https://github.com/shlinkio/shlink/issues/1157) All routes now support CORS, not only rest ones. +* [#1144](https://github.com/shlinkio/shlink/issues/1144) Added experimental builds under PHP 8.1. + +### Deprecated +* [#1164](https://github.com/shlinkio/shlink/issues/1164) Deprecated `SHORT_DOMAIN_HOST` and `SHORT_DOMAIN_SCHEMA` env vars. Use `DEFAULT_DOMAIN` and `USE_HTTPS=true|false` instead. + +### Removed +* *Nothing* + +### Fixed +* [#1165](https://github.com/shlinkio/shlink/issues/1165) Fixed warning displayed when trying to locate visits and there are none pending. +* [#1172](https://github.com/shlinkio/shlink/pull/1172) Removed unneeded explicitly defined volumes in docker image. + + +## [2.8.1] - 2021-08-15 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1155](https://github.com/shlinkio/shlink/issues/1155) Fixed numeric query params in long URLs being replaced by `0`. + + +## [2.8.0] - 2021-08-04 +### Added +* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`. +* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes. + + Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High. + +* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL. + + With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`. + + This behavior needs to be actively opted in, via installer config options or env vars. + +* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink. + + Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain. + +### Changed +* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. +* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24. +* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14. + +### Deprecated +* *Nothing* + +### Removed +* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4. + +### Fixed +* [#1098](https://github.com/shlinkio/shlink/issues/1098) Fixed errors when using Redis for caching, caused by some third party lib bug that was fixed on dependencies update. + + +## [2.7.3] - 2021-08-02 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1135](https://github.com/shlinkio/shlink/issues/1135) Fixed error when importing short URLs with no visits from another Shlink instance. +* [#1136](https://github.com/shlinkio/shlink/issues/1136) Fixed error when fetching tag/short-url/orphan visits for a page lower than 1. + + +## [2.7.2] - 2021-07-30 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1128](https://github.com/shlinkio/shlink/issues/1128) Increased memory limit reserved for the docker image, preventing it from crashing on GeoLite db download. + + +## [2.7.1] - 2021-05-30 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1100](https://github.com/shlinkio/shlink/issues/1100) Fixed Shlink trying to download GeoLite2 db files even when tracking has been disabled. + + +## [2.7.0] - 2021-05-23 +### Added +* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows. +* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole. + + The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update. + + Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one. + +* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line. +* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API. +* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it. + + In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer: + + * `disable_tracking`: If true, visits will not be tracked at all. + * `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved. + * `disable_referrer_tracking`: If true, the referrer will not be tracked. + * `disable_ua_tracking`: If true, the user agent will not be tracked. + +* [#955](https://github.com/shlinkio/shlink/issues/955) Added new option to set short URLs as crawlable, making them be listed in the robots.txt as Allowed. +* [#900](https://github.com/shlinkio/shlink/issues/900) Shlink now tries to detect if the visit is coming from a potential bot or crawler, and allows to exclude those visits from visits lists if desired. + +### Changed +* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0. +* [#1039](https://github.com/shlinkio/shlink/issues/1039) Updated to `endroid/qr-code` 4.0. +* [#1008](https://github.com/shlinkio/shlink/issues/1008) Ensured all logs are sent to the filesystem while running API tests, which helps debugging the reason for tests to fail. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1041](https://github.com/shlinkio/shlink/issues/1041) Ensured the default value for the version while building the docker image is `latest`. +* [#1067](https://github.com/shlinkio/shlink/issues/1067) Fixed exception when persisting multiple short URLs in one batch which include the same new tags/domains. This can potentially happen when importing URLs. + + +## [2.6.2] - 2021-03-12 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1047](https://github.com/shlinkio/shlink/issues/1047) Fixed error in migrations when doing a fresh installation using PHP8 and MySQL/Mariadb databases. + + +## [2.6.1] - 2021-02-22 +### Added +* *Nothing* + +### Changed +* [#1026](https://github.com/shlinkio/shlink/issues/1026) Removed non-inclusive terms from source code. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1024](https://github.com/shlinkio/shlink/issues/1024) Fixed migration that is incorrectly skipped due to the wrong condition being used to check it. +* [#1031](https://github.com/shlinkio/shlink/issues/1031) Fixed shortening of twitter URLs with URL validation enabled. +* [#1034](https://github.com/shlinkio/shlink/issues/1034) Fixed warning displayed when shlink is stopped while running it with swoole. + + +## [2.6.0] - 2021-02-13 +### Added +* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support. +* [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL. + + The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in. + +* [#913](https://github.com/shlinkio/shlink/issues/913) Added support to import short URLs from a standard CSV file. + + The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns. + +* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code. +* [#675](https://github.com/shlinkio/shlink/issues/675) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits. + + This behavior is enabled by default, but you can opt out via env vars or config options. + + This new orphan visits can be consumed in these ways: + + * The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs. + * The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits. + +### Changed +* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. +* [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. +* [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes. +* [#874](https://github.com/shlinkio/shlink/issues/874) Changed how dist files are generated. Now there will be two for every supported PHP version, with and without support for swoole. + + The dist files will have been built under the same PHP version they are meant to be run under, ensuring resolved dependencies are the proper ones. + +### Deprecated +* [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`). + + All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0 + +* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated the endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`). + + The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead. + +### Removed +* *Nothing* + +### Fixed +* [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers. +* [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used. +* [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int. +* [#851](https://github.com/shlinkio/shlink/issues/851) Fixed error when trying to schedule swoole tasks in ARM architectures (like raspberry). + + +## [2.5.2] - 2021-01-24 +### Added +* [#965](https://github.com/shlinkio/shlink/issues/965) Added docs section for Architectural Decision Records, including the one for API key roles. + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short URLs list. +* [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address. +* [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods. + + +## [2.5.1] - 2021-01-21 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#968](https://github.com/shlinkio/shlink/issues/968) Fixed index error in MariaDB while updating to v2.5.0. +* [#972](https://github.com/shlinkio/shlink/issues/972) Fixed 500 error when calling single-step short URL creation endpoint. + + +## [2.5.0] - 2021-01-17 +### Added +* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys. + + API keys can have any combinations of these two roles now, allowing to limit their interactions: + + * Can interact only with short URLs created with that API key. + * Can interact only with short URLs for a specific domain. + +* [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database. + + It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image. + +* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10. +* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs. +* [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances. +* [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it. + +### Changed +* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package. +* [#875](https://github.com/shlinkio/shlink/issues/875) Updated to `mezzio/mezzio-swoole` v3.1. +* [#952](https://github.com/shlinkio/shlink/issues/952) Simplified in-project docs, by keeping only the basics and linking to the websites docs for anything else. + +### Deprecated +* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`. +* [#924](https://github.com/shlinkio/shlink/issues/924) Deprecated mechanism to provide config options to the docker image through volumes. Use the env vars instead as a direct replacement. + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [2.4.2] - 2020-11-22 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#904](https://github.com/shlinkio/shlink/issues/904) Explicitly added missing "Domains" and "Integrations" tags to swagger docs. +* [#901](https://github.com/shlinkio/shlink/issues/901) Ensured domains which are not in use on any short URL are not returned on the list of domains. +* [#899](https://github.com/shlinkio/shlink/issues/899) Avoided filesystem errors produced while downloading geolite DB files on several shlink instances that share the same filesystem. +* [#827](https://github.com/shlinkio/shlink/issues/827) Fixed swoole config getting loaded in config cache if a console command is run before any web execution, when swoole extension is enabled, making subsequent non-swoole web requests fail. + + +## [2.4.1] - 2020-11-10 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#891](https://github.com/shlinkio/shlink/issues/891) Fixed error when running migrations in postgres due to incorrect return type hint. +* [#846](https://github.com/shlinkio/shlink/issues/846) Fixed base image used for the PHP-FPM dev container. +* [#867](https://github.com/shlinkio/shlink/issues/867) Fixed not-found redirects not using proper status (301 or 302) as configured during installation. + + +## [2.4.0] - 2020-11-08 +### Added +* [#829](https://github.com/shlinkio/shlink/issues/829) Added support for QR codes in SVG format, by passing `?format=svg` to the QR code URL. +* [#820](https://github.com/shlinkio/shlink/issues/820) Added new option to force enabling or disabling URL validation on a per-URL basis. + + Currently, there's a global config that tells if long URLs should be validated (by ensuring they are publicly accessible and return a 2xx status). However, this is either always applied or never applied. + + Now, it is possible to enforce validation or enforce disabling validation when a new short URL is created or edited: + + * On the `POST /short-url` and `PATCH /short-url/{shortCode}` endpoints, you can now pass `validateUrl: true/false` in order to enforce enabling or disabling validation, ignoring the global config. If the value is not provided, the global config is still normally applied. + * On the `short-url:generate` CLI command, you can pass `--validate-url` or `--no-validate-url` flags, in order to enforce enabling or disabling validation. If none of them is provided, the global config is still normally applied. + +* [#838](https://github.com/shlinkio/shlink/issues/838) Added new endpoint and CLI command to list existing domains. + + It returns both default domain and specific domains that were used for some short URLs. + + * REST endpoint: `GET /rest/v2/domains` + * CLI Command: `domain:list` + +* [#832](https://github.com/shlinkio/shlink/issues/832) Added support to customize the port in which the docker image listens by using the `PORT` env var or the `port` config option. + +* [#860](https://github.com/shlinkio/shlink/issues/860) Added support to import links from bit.ly. + + Run the command `short-urls:import bitly` and introduce requested information in order to import all your links. + + Other sources will be supported in future releases. + +### Changed +* [#836](https://github.com/shlinkio/shlink/issues/836) Added support for the `-` notation while determining how to order the short URLs list, as in `?orderBy=shortCode-DESC`. This effectively deprecates the array notation (`?orderBy[shortCode]=DESC`), that will be removed in Shlink 3.0.0 +* [#782](https://github.com/shlinkio/shlink/issues/782) Added code coverage to API tests. +* [#858](https://github.com/shlinkio/shlink/issues/858) Updated to latest infection version. Updated docker images to PHP 7.4.11 and swoole 4.5.5 +* [#887](https://github.com/shlinkio/shlink/pull/887) Started tracking the API key used to create short URLs, in order to allow restrictions in future releases. + +### Deprecated +* [#883](https://github.com/shlinkio/shlink/issues/883) Deprecated `POST /tags` endpoint and `tag:create` command, as tags are created automatically while creating short URLs. + +### Removed +* *Nothing* + +### Fixed +* [#837](https://github.com/shlinkio/shlink/issues/837) Drastically improved performance when creating a new shortUrl and providing `findIfExists = true`. +* [#878](https://github.com/shlinkio/shlink/issues/878) Added missing `gmp` extension to the official docker image. + + +## [2.3.0] - 2020-08-09 +### Added +* [#746](https://github.com/shlinkio/shlink/issues/746) Allowed to configure the kind of redirect you want to use for your short URLs. You can either set: + + * `302` redirects: Default behavior. Visitors always hit the server. + * `301` redirects: Better for SEO. Visitors hit the server the first time and then cache the redirect. + + When selecting 301 redirects, you can also configure the time redirects are cached, to mitigate deviations in stats. + +* [#734](https://github.com/shlinkio/shlink/issues/734) Added support to redirect to deeplinks and other links with schemas different from `http` and `https`. +* [#709](https://github.com/shlinkio/shlink/issues/709) Added multi-architecture builds for the docker image. + +* [#707](https://github.com/shlinkio/shlink/issues/707) Added `--all` flag to `short-urls:list` command, which will print all existing URLs in one go, with no pagination. + + It has one limitation, though. Because of the way the CLI tooling works, all rows in the table must be loaded in memory. If the amount of URLs is too high, the command may fail due to too much memory usage. + +### Changed +* [#508](https://github.com/shlinkio/shlink/issues/508) Added mutation checks to database tests. +* [#790](https://github.com/shlinkio/shlink/issues/790) Updated to doctrine/migrations v3. +* [#798](https://github.com/shlinkio/shlink/issues/798) Updated to guzzlehttp/guzzle v7. +* [#822](https://github.com/shlinkio/shlink/issues/822) Updated docker image to use PHP 7.4.9 with Alpine 3.12 and swoole 4.5.2. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [2.2.2] - 2020-06-08 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#769](https://github.com/shlinkio/shlink/issues/769) Fixed custom slugs not allowing valid URL characters, like `.`, `_` or `~`. +* [#781](https://github.com/shlinkio/shlink/issues/781) Fixed memory leak when loading visits for a tag which is used for big amounts of short URLs. + + +## [2.2.1] - 2020-05-11 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#764](https://github.com/shlinkio/shlink/issues/764) Fixed error when trying to match an existing short URL which does not have `validSince` and/or `validUntil`, but you are providing either one of them for the new one. + + +## [2.2.0] - 2020-05-09 +### Added +* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server. + + Thanks to that, Shlink will be able to publish events that can be consumed in real time. + + For now, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl: + + * A visit occurs on any short URL: `https://shlink.io/new-visit`. + * A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`. + + The updates are only published when serving Shlink with swoole. + + Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subscribe to updates. + +* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats. +* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag. + + It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag. + +* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag. + + Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag. + + Also, the `tag:list` CLI command has been changed and it always behaves like this. + +* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation. + +### Changed +* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets. +* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered. +* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql. +* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled. +* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired. +* [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer. + + +## [2.1.4] - 2020-04-30 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#742](https://github.com/shlinkio/shlink/issues/742) Allowed a custom GeoLite2 license key to be provided, in order to avoid download limits. + + +## [2.1.3] - 2020-04-09 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#712](https://github.com/shlinkio/shlink/issues/712) Fixed app set-up not clearing entities metadata cache. +* [#711](https://github.com/shlinkio/shlink/issues/711) Fixed `HEAD` requests returning a duplicated `Content-Length` header. +* [#716](https://github.com/shlinkio/shlink/issues/716) Fixed Twitter not properly displaying preview for final long URL. +* [#717](https://github.com/shlinkio/shlink/issues/717) Fixed DB connection expiring on task workers when using swoole. +* [#705](https://github.com/shlinkio/shlink/issues/705) Fixed how the short URL domain is inferred when generating QR codes, making sure the configured domain is respected even if the request is performed using a different one, and only when a custom domain is used, then that one is used instead. + + +## [2.1.2] - 2020-03-29 +### Added +* *Nothing* + +### Changed +* [#696](https://github.com/shlinkio/shlink/issues/696) Updated to infection v0.16. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#700](https://github.com/shlinkio/shlink/issues/700) Fixed migration not working with postgres. +* [#690](https://github.com/shlinkio/shlink/issues/690) Fixed tags being incorrectly sluggified when filtering short URL lists, making results not be the expected. + + +## [2.1.1] - 2020-03-28 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#697](https://github.com/shlinkio/shlink/issues/697) Recovered `.htaccess` file that was unintentionally removed in v2.1.0, making Shlink unusable with Apache. + + +## [2.1.0] - 2020-03-28 +### Added +* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server. +* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis. +* [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries. +* [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole. +* [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint. + +### Changed +* [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9. +* [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`. + + * When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue. + * When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed. +* [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug. +* [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way. + + +## [2.0.5] - 2020-02-09 +### Added +* [#651](https://github.com/shlinkio/shlink/issues/651) Documented how Shlink behaves when using multiple domains. + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#648](https://github.com/shlinkio/shlink/issues/648) Ensured any user can write in log files, in case shlink is run by several system users. +* [#650](https://github.com/shlinkio/shlink/issues/650) Ensured default domain is ignored when trying to create a short URL. + + +## [2.0.4] - 2020-02-02 +### Added +* *Nothing* + +### Changed +* [#577](https://github.com/shlinkio/shlink/issues/577) Wrapped params used to customize short URL lists into a DTO with implicit validation. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#620](https://github.com/shlinkio/shlink/issues/620) Ensured "controlled" errors (like validation errors and such) won't be logged with error level, preventing logs to be polluted. +* [#637](https://github.com/shlinkio/shlink/issues/637) Fixed several work flows in which short URLs with domain are handled form the API. +* [#644](https://github.com/shlinkio/shlink/issues/644) Fixed visits to short URL on non-default domain being linked to the URL on default domain with the same short code. +* [#643](https://github.com/shlinkio/shlink/issues/643) Fixed searching on short URL lists not taking into consideration the domain name. + + +## [2.0.3] - 2020-01-27 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected. +* [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset. +* [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled. +* [#621](https://github.com/shlinkio/shlink/issues/621) Fixed permission denied error when updating same GeoLite file version more than once. + + +## [2.0.2] - 2020-01-12 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#614](https://github.com/shlinkio/shlink/issues/614) Fixed `OPTIONS` requests including the `Origin` header not always returning an empty body with status 2xx. +* [#615](https://github.com/shlinkio/shlink/issues/615) Fixed query args with no value being lost from the long URL when users are redirected. + + +## [2.0.1] - 2020-01-10 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#607](https://github.com/shlinkio/shlink/issues/607) Added missing info on UPGRADE.md doc. +* [#610](https://github.com/shlinkio/shlink/issues/610) Fixed use of hardcoded quotes on a database migration which makes it fail on postgres. +* [#605](https://github.com/shlinkio/shlink/issues/605) Fixed crashes occurring when migrating from old Shlink versions with nullable DB columns that are assigned to non-nullable entity typed props. + + +## [2.0.0] - 2020-01-08 +### Added +* [#429](https://github.com/shlinkio/shlink/issues/429) Added support for PHP 7.4 +* [#529](https://github.com/shlinkio/shlink/issues/529) Created an UPGRADING.md file explaining how to upgrade from v1.x to v2.x +* [#594](https://github.com/shlinkio/shlink/issues/594) Updated external shlink packages, including installer v4.0, which adds the option to ask for the redis cluster config. + +### Changed +* [#592](https://github.com/shlinkio/shlink/issues/592) Updated coding styles to use [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) v2.1.0. +* [#530](https://github.com/shlinkio/shlink/issues/530) Migrated project from deprecated `zendframework` components to the new `laminas` and `mezzio` ones. + +### Deprecated +* *Nothing* + +### Removed +* [#429](https://github.com/shlinkio/shlink/issues/429) Dropped support for PHP 7.2 and 7.3 + +* [#229](https://github.com/shlinkio/shlink/issues/229) Remove everything which was deprecated, including: + + * Preview generation feature completely removed. + * Authentication against REST API using JWT is no longer supported. + + See [UPGRADE](UPGRADE.md#from-v1x-to-v2x) doc in order to get details on how to migrate to this version. + +### Fixed +* [#600](https://github.com/shlinkio/shlink/issues/600) Fixed health action so that it works with and without version in the path. From e586fec338571903b2d2330a5bc44526bdcb37e4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Apr 2024 08:53:31 +0200 Subject: [PATCH 64/64] Rearrange changelog --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 743301d2..08738f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space. * [#1925](https://github.com/shlinkio/shlink/issues/1925) Add new `integration:matomo:send-visits` console command that can be used to send existing visits to integrated Matomo instance. +* [#2087](https://github.com/shlinkio/shlink/issues/2087) Allow `memory_limit` to be configured via the new `MEMORY_LIMIT` env var or configuration option. ### Changed * [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible. @@ -33,8 +34,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This allows for a better traceability, as the logs generated during those jobs will have a matching UUID as the logs generated during the request the triggered the job. -* [#2087](https://github.com/shlinkio/shlink/issues/2087) Allow `memory_limit` to be configured via the new `MEMORY_LIMIT` env var or configuration option. - ### Deprecated * *Nothing*