Repository: shlinkio/shlink (language: php)

Issue:
Granularly disable logo in QR codes
### Summary

It would be very helpful if it were possible to disable the logo in the QR code per URL. This could look like this in the web client:

![](https://i.postimg.cc/jdBhQgVg/Screenshot-2024-10-18-072148.png)

Alternatively, it might be better to allow multiple logos. Then a dropdown menu would be conceivable in which the logo could be selected or no logo at all.

### Use case

There are situations in which no logo should be displayed in the QR code. It should not be necessary to set up a new instance of Shlink without a logo for this.


Discussion / hints:
Thanks for the suggestion.

Allowing to disable the logo should be easy enough, and I agree it is useful, so I'll get it implemented.

As per supporting multiple logos, it may be trickier, as providing a map of values via env vars/config options is very error-prone.

I'm considering [some improvements](https://github.com/shlinkio/shlink/discussions/2234) over how Shlink handles QR codes, that may make it easier to support multiple logos in future. I'll probably wait for that to be implemented first.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 241b734ed..25c508a8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
 
     All [URI-reserved characters](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) were disallowed up until now, but from now on, only the gen-delimiters are.
 
+* [#2229](https://github.com/shlinkio/shlink/issues/2229) Add `logo=disabled` query param to dynamically disable the default logo on QR codes.
+
 ### Changed
 * [#2281](https://github.com/shlinkio/shlink/issues/2281) Update docker image to PHP 8.4
 * [#2124](https://github.com/shlinkio/shlink/issues/2124) Improve how Shlink decides if a GeoLite db file needs to be downloaded, and reduces the chances for API limits to be reached.
diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json
index 39be2dc9e..bb95f7ef1 100644
--- a/docs/swagger/paths/{shortCode}_qr-code.json
+++ b/docs/swagger/paths/{shortCode}_qr-code.json
@@ -85,6 +85,16 @@
                     "type": "string",
                     "default": "#ffffff"
                 }
+            },
+            {
+                "name": "logo",
+                "in": "query",
+                "description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
+                "required": false,
+                "schema": {
+                    "type": "string",
+                    "enum": ["disable"]
+                }
             }
         ],
         "responses": {
diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php
index 8bc5b317e..459c99b7d 100644
--- a/module/Core/src/Action/Model/QrCodeParams.php
+++ b/module/Core/src/Action/Model/QrCodeParams.php
@@ -28,20 +28,21 @@
 use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
 use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
 
-final class QrCodeParams
+final readonly class QrCodeParams
 {
     private const int MIN_SIZE = 50;
     private const int MAX_SIZE = 1000;
     private const array SUPPORTED_FORMATS = ['png', 'svg'];
 
     private function __construct(
-        public readonly int $size,
-        public readonly int $margin,
-        public readonly WriterInterface $writer,
-        public readonly ErrorCorrectionLevel $errorCorrectionLevel,
-        public readonly RoundBlockSizeMode $roundBlockSizeMode,
-        public readonly ColorInterface $color,
-        public readonly ColorInterface $bgColor,
+        public int $size,
+        public int $margin,
+        public WriterInterface $writer,
+        public ErrorCorrectionLevel $errorCorrectionLevel,
+        public RoundBlockSizeMode $roundBlockSizeMode,
+        public ColorInterface $color,
+        public ColorInterface $bgColor,
+        public bool $disableLogo,
     ) {
     }
 
@@ -57,6 +58,7 @@ public static function fromRequest(ServerRequestInterface $request, QrCodeOption
             roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults),
             color: self::resolveColor($query, $defaults),
             bgColor: self::resolveBackgroundColor($query, $defaults),
+            disableLogo: isset($query['logo']) && $query['logo'] === 'disable',
         );
     }
 
diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php
index 607fab50b..fbc83c218 100644
--- a/module/Core/src/Action/QrCodeAction.php
+++ b/module/Core/src/Action/QrCodeAction.php
@@ -60,7 +60,7 @@ public function process(Request $request, RequestHandlerInterface $handler): Res
     private function buildQrCode(Builder $qrCodeBuilder, QrCodeParams $params): ResultInterface
     {
         $logoUrl = $this->options->logoUrl;
-        if ($logoUrl === null) {
+        if ($logoUrl === null || $params->disableLogo) {
             return $qrCodeBuilder->build();
         }
 


Test patch:
diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php
index 688badee1..9db42752a 100644
--- a/module/Core/test/Action/QrCodeActionTest.php
+++ b/module/Core/test/Action/QrCodeActionTest.php
@@ -9,6 +9,7 @@
 use Laminas\Diactoros\ServerRequestFactory;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestWith;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\ServerRequestInterface;
@@ -294,11 +295,16 @@ public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls
     }
 
     #[Test]
-    public function logoIsAddedToQrCodeIfOptionIsDefined(): void
+    #[TestWith([[], '4696E5'])] // The logo has Shlink's brand color
+    #[TestWith([['logo' => 'invalid'], '4696E5'])] // Invalid `logo` values are ignored. Default logo is still rendered
+    #[TestWith([['logo' => 'disable'], '000000'])] // No logo will be added if explicitly disabled
+    public function logoIsAddedToQrCodeIfOptionIsDefined(array $query, string $expectedColor): void
     {
-        $logoUrl = 'https://avatars.githubusercontent.com/u/20341790?v=4'; // Shlink logo
+        $logoUrl = 'https://avatars.githubusercontent.com/u/20341790?v=4'; // Shlink's logo
         $code = 'abc123';
-        $req = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $code);
+        $req = ServerRequestFactory::fromGlobals()
+            ->withAttribute('shortCode', $code)
+            ->withQueryParams($query);
 
         $this->urlResolver->method('resolveEnabledShortUrl')->with(
             ShortUrlIdentifier::fromShortCodeAndDomain($code),
@@ -309,9 +315,9 @@ public function logoIsAddedToQrCodeIfOptionIsDefined(): void
         $image = imagecreatefromstring($resp->getBody()->__toString());
         self::assertNotFalse($image);
 
-        // At around 100x100 px we can already find the logo, which has Shlink's brand color
+        // At around 100x100 px we can already find the logo, if present
         $resultingColor = imagecolorat($image, 100, 100);
-        self::assertEquals(hexdec('4696E5'), $resultingColor);
+        self::assertEquals(hexdec($expectedColor), $resultingColor);
     }
 
     public static function provideEnabled(): iterable