diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb731dc..cced1725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag. +* [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo. + ### Changed * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. * [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. diff --git a/composer.json b/composer.json index 3ce4f63a..8f9b86a6 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "cakephp/chronos": "^3.0.2", "doctrine/migrations": "^3.6", "doctrine/orm": "^3.0", - "endroid/qr-code": "^4.8", + "endroid/qr-code": "^5.0", "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.5", @@ -42,11 +42,11 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#762b3b8 as 6.0", + "shlinkio/shlink-common": "dev-main#b9a6bd5 as 6.0", "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", - "shlinkio/shlink-installer": "dev-develop#b314455 as 9.0", + "shlinkio/shlink-installer": "dev-develop#41e433c as 9.0", "shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index a3b477af..753bdb9a 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -59,6 +59,9 @@ return [ Option\QrCode\DefaultFormatConfigOption::class, Option\QrCode\DefaultErrorCorrectionConfigOption::class, Option\QrCode\DefaultRoundBlockSizeConfigOption::class, + Option\QrCode\DefaultColorConfigOption::class, + Option\QrCode\DefaultBgColorConfigOption::class, + Option\QrCode\DefaultLogoUrlConfigOption::class, Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class, diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index 808ff961..919beffa 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; @@ -26,6 +28,9 @@ return [ 'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv( DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, ), + 'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(DEFAULT_QR_CODE_COLOR), + 'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(DEFAULT_QR_CODE_BG_COLOR), + 'logo_url' => EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(), ], ]; diff --git a/config/constants.php b/config/constants.php index 7b263262..51ee0476 100644 --- a/config/constants.php +++ b/config/constants.php @@ -20,4 +20,5 @@ const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; -const MIN_TASK_WORKERS = 4; +const DEFAULT_QR_CODE_COLOR = '#000000'; // Black +const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 05181f20..2a3907cc 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -4,24 +4,30 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action\Model; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeInterface; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeNone; +use Endroid\QrCode\Color\Color; +use Endroid\QrCode\Color\ColorInterface; +use Endroid\QrCode\ErrorCorrectionLevel; +use Endroid\QrCode\RoundBlockSizeMode; use Endroid\QrCode\Writer\PngWriter; use Endroid\QrCode\Writer\SvgWriter; use Endroid\QrCode\Writer\WriterInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\QrCodeOptions; +use function ctype_xdigit; +use function hexdec; +use function ltrim; +use function max; +use function min; use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function strlen; use function strtolower; +use function substr; use function trim; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; + final class QrCodeParams { private const MIN_SIZE = 50; @@ -32,8 +38,10 @@ final class QrCodeParams public readonly int $size, public readonly int $margin, public readonly WriterInterface $writer, - public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel, - public readonly RoundBlockSizeModeInterface $roundBlockSizeMode, + public readonly ErrorCorrectionLevel $errorCorrectionLevel, + public readonly RoundBlockSizeMode $roundBlockSizeMode, + public readonly ColorInterface $color, + public readonly ColorInterface $bgColor, ) { } @@ -42,11 +50,13 @@ final class QrCodeParams $query = $request->getQueryParams(); return new self( - self::resolveSize($query, $defaults), - self::resolveMargin($query, $defaults), - self::resolveWriter($query, $defaults), - self::resolveErrorCorrection($query, $defaults), - self::resolveRoundBlockSize($query, $defaults), + size: self::resolveSize($query, $defaults), + margin: self::resolveMargin($query, $defaults), + writer: self::resolveWriter($query, $defaults), + errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults), + roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults), + color: self::resolveColor($query, $defaults), + bgColor: self::resolveBackgroundColor($query, $defaults), ); } @@ -57,7 +67,7 @@ final class QrCodeParams return self::MIN_SIZE; } - return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; + return min($size, self::MAX_SIZE); } private static function resolveMargin(array $query, QrCodeOptions $defaults): int @@ -68,7 +78,7 @@ final class QrCodeParams return 0; } - return $intMargin < 0 ? 0 : $intMargin; + return max($intMargin, 0); } private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface @@ -82,23 +92,57 @@ final class QrCodeParams }; } - private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface + private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevel { $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection); return match ($errorCorrectionLevel) { - 'h' => new ErrorCorrectionLevelHigh(), - 'q' => new ErrorCorrectionLevelQuartile(), - 'm' => new ErrorCorrectionLevelMedium(), - default => new ErrorCorrectionLevelLow(), // 'l' + 'h' => ErrorCorrectionLevel::High, + 'q' => ErrorCorrectionLevel::Quartile, + 'm' => ErrorCorrectionLevel::Medium, + default => ErrorCorrectionLevel::Low, // 'l' }; } - private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeModeInterface + private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeMode { $doNotRoundBlockSize = isset($query['roundBlockSize']) ? $query['roundBlockSize'] === 'false' : ! $defaults->roundBlockSize; - return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin(); + return $doNotRoundBlockSize ? RoundBlockSizeMode::None : RoundBlockSizeMode::Margin; + } + + private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface + { + $color = self::normalizeParam($query['color'] ?? $defaults->color); + return self::parseHexColor($color, DEFAULT_QR_CODE_COLOR); + } + + private static function resolveBackgroundColor(array $query, QrCodeOptions $defaults): ColorInterface + { + $bgColor = self::normalizeParam($query['bgColor'] ?? $defaults->bgColor); + return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR); + } + + private static function parseHexColor(string $hexColor, ?string $fallback): Color + { + $hexColor = ltrim($hexColor, '#'); + if (! ctype_xdigit($hexColor) && $fallback !== null) { + return self::parseHexColor($fallback, null); + } + + if (strlen($hexColor) === 3) { + return new Color( + (int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), + (int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), + (int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), + ); + } + + return new Color( + (int) hexdec(substr($hexColor, 0, 2)), + (int) hexdec(substr($hexColor, 2, 2)), + (int) hexdec(substr($hexColor, 4, 2)), + ); } private static function normalizeParam(string $param): string diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index a952243a..53fb1251 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -48,7 +48,15 @@ readonly class QrCodeAction implements MiddlewareInterface ->margin($params->margin) ->writer($params->writer) ->errorCorrectionLevel($params->errorCorrectionLevel) - ->roundBlockSizeMode($params->roundBlockSizeMode); + ->roundBlockSizeMode($params->roundBlockSizeMode) + ->foregroundColor($params->color) + ->backgroundColor($params->bgColor); + + $logoUrl = $this->options->logoUrl; + if ($logoUrl !== null) { + $qrCodeBuilder->logoPath($logoUrl) + ->logoResizeToHeight((int) ($params->size / 4)); + } return new QrCodeResponse($qrCodeBuilder->build()); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 14a850c9..0ea74451 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -45,6 +45,9 @@ enum EnvVars: string case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS'; + case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR'; + case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR'; + case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL'; case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; diff --git a/module/Core/src/Options/QrCodeOptions.php b/module/Core/src/Options/QrCodeOptions.php index fff27858..da130d17 100644 --- a/module/Core/src/Options/QrCodeOptions.php +++ b/module/Core/src/Options/QrCodeOptions.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; @@ -20,6 +22,9 @@ readonly final class QrCodeOptions public string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION, public bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, + public string $color = DEFAULT_QR_CODE_COLOR, + public string $bgColor = DEFAULT_QR_CODE_BG_COLOR, + public ?string $logoUrl = null, ) { } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 98e1e375..08564bf9 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -24,9 +24,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use function getimagesizefromstring; +use function hexdec; use function imagecolorat; use function imagecreatefromstring; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; + class QrCodeActionTest extends TestCase { private const WHITE = 0xFFFFFF; @@ -46,10 +49,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); - $delegate = $this->createMock(RequestHandlerInterface::class); - $delegate->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); - $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); + $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); } #[Test] @@ -59,10 +62,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); - $delegate->expects($this->never())->method('handle'); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); - $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); + $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); self::assertInstanceOf(QrCodeResponse::class, $resp); self::assertEquals(200, $resp->getStatusCode()); @@ -78,10 +81,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); - $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $delegate); + $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $handler); self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); } @@ -108,9 +111,9 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); + $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $handler); $result = getimagesizefromstring($resp->getBody()->__toString()); self::assertNotFalse($result); @@ -198,14 +201,14 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req, $delegate); + $resp = $this->action($defaultOptions)->process($req, $handler); $image = imagecreatefromstring($resp->getBody()->__toString()); self::assertNotFalse($image); $color = imagecolorat($image, 1, 1); - self::assertEquals($color, $expectedColor); + self::assertEquals($expectedColor, $color); } public static function provideRoundBlockSize(): iterable @@ -230,10 +233,47 @@ class QrCodeActionTest extends TestCase ]; } + #[Test, DataProvider('provideColors')] + public function properColorsAreUsed(?string $queryColor, ?string $optionsColor, int $expectedColor): void + { + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals() + ->withQueryParams(['color' => $queryColor]) + ->withAttribute('shortCode', $code); + + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $handler = $this->createMock(RequestHandlerInterface::class); + + $resp = $this->action( + new QrCodeOptions(size: 250, roundBlockSize: false, color: $optionsColor ?? DEFAULT_QR_CODE_COLOR), + )->process($req, $handler); + $image = imagecreatefromstring($resp->getBody()->__toString()); + self::assertNotFalse($image); + + $resultingColor = imagecolorat($image, 1, 1); + self::assertEquals($expectedColor, $resultingColor); + } + + public static function provideColors(): iterable + { + yield 'no query, no default' => [null, null, self::BLACK]; + yield '6-char-query black' => ['000000', null, self::BLACK]; + yield '6-char-query white' => ['ffffff', null, self::WHITE]; + yield '6-char-query red' => ['ff0000', null, (int) hexdec('ff0000')]; + yield '3-char-query black' => ['000', null, self::BLACK]; + yield '3-char-query white' => ['fff', null, self::WHITE]; + yield '3-char-query red' => ['f00', null, (int) hexdec('ff0000')]; + yield '3-char-default red' => [null, 'f00', (int) hexdec('ff0000')]; + yield 'invalid color in query' => ['zzzzzzzz', null, self::BLACK]; + yield 'invalid color in query with default' => ['zzzzzzzz', 'aa88cc', self::BLACK]; + yield 'invalid color in default' => [null, 'zzzzzzzz', self::BLACK]; + } + #[Test, DataProvider('provideEnabled')] public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls): void { - if ($enabledForDisabledShortUrls) { $this->urlResolver->expects($this->once())->method('resolvePublicShortUrl')->willThrowException( ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')), @@ -253,6 +293,27 @@ class QrCodeActionTest extends TestCase ); } + #[Test] + public function logoIsAddedToQrCodeIfOptionIsDefined(): void + { + $logoUrl = 'https://avatars.githubusercontent.com/u/20341790?v=4'; // Shlink logo + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $code); + + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $handler = $this->createMock(RequestHandlerInterface::class); + + $resp = $this->action(new QrCodeOptions(size: 250, logoUrl: $logoUrl))->process($req, $handler); + $image = imagecreatefromstring($resp->getBody()->__toString()); + self::assertNotFalse($image); + + // At around 100x100 px we can already find the logo, which has Shlink's brand color + $resultingColor = imagecolorat($image, 100, 100); + self::assertEquals(hexdec('4696E5'), $resultingColor); + } + public static function provideEnabled(): iterable { yield 'always enabled' => [true]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5abec3eb..9c85d2c4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,7 @@ bootstrap="./vendor/autoload.php" colors="true" cacheDirectory="build/.phpunit/unit-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" >