diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php
new file mode 100644
index 00000000..1cf6fecb
--- /dev/null
+++ b/config/autoload/qr-codes.global.php
@@ -0,0 +1,21 @@
+ [
+ 'size' => (int) env('DEFAULT_QR_CODE_SIZE', DEFAULT_QR_CODE_SIZE),
+ 'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN),
+ 'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT),
+ 'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION),
+ ],
+
+];
diff --git a/config/constants.php b/config/constants.php
index 5a270e3c..43de270a 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -14,3 +14,7 @@ const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
const TITLE_TAG_VALUE = '/
]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
+const DEFAULT_QR_CODE_SIZE = 300;
+const DEFAULT_QR_CODE_MARGIN = 0;
+const DEFAULT_QR_CODE_FORMAT = 'png';
+const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php
index 7f28b14d..66d854c3 100644
--- a/module/Core/config/dependencies.config.php
+++ b/module/Core/config/dependencies.config.php
@@ -25,6 +25,7 @@ return [
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
+ Options\QrCodeOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class,
Service\ShortUrlService::class => ConfigAbstractFactory::class,
@@ -86,6 +87,7 @@ return [
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
+ Options\QrCodeOptions::class => ['config.qr_codes'],
Service\UrlShortener::class => [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
@@ -138,6 +140,7 @@ return [
Service\ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlStringifier::class,
'Logger_Shlink',
+ Options\QrCodeOptions::class,
],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php
index 39cd59a9..0e889c32 100644
--- a/module/Core/src/Action/Model/QrCodeParams.php
+++ b/module/Core/src/Action/Model/QrCodeParams.php
@@ -14,15 +14,17 @@ use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
+use Shlinkio\Shlink\Core\Options\QrCodeOptions;
+use function Functional\contains;
use function strtolower;
use function trim;
final class QrCodeParams
{
- private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
+ private const SUPPORTED_FORMATS = ['png', 'svg'];
private function __construct(
private int $size,
@@ -32,22 +34,22 @@ final class QrCodeParams
) {
}
- public static function fromRequest(ServerRequestInterface $request): self
+ public static function fromRequest(ServerRequestInterface $request, QrCodeOptions $defaults): self
{
$query = $request->getQueryParams();
return new self(
- self::resolveSize($request, $query),
- self::resolveMargin($query),
- self::resolveWriter($query),
- self::resolveErrorCorrection($query),
+ self::resolveSize($request, $query, $defaults),
+ self::resolveMargin($query, $defaults),
+ self::resolveWriter($query, $defaults),
+ self::resolveErrorCorrection($query, $defaults),
);
}
- private static function resolveSize(Request $request, array $query): int
+ private static function resolveSize(Request $request, array $query, QrCodeOptions $defaults): int
{
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
- $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
+ $size = (int) $request->getAttribute('size', $query['size'] ?? $defaults->size());
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
@@ -55,13 +57,9 @@ final class QrCodeParams
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
- private static function resolveMargin(array $query): int
+ private static function resolveMargin(array $query, QrCodeOptions $defaults): int
{
- $margin = $query['margin'] ?? null;
- if ($margin === null) {
- return 0;
- }
-
+ $margin = $query['margin'] ?? (string) $defaults->margin();
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
@@ -70,18 +68,20 @@ final class QrCodeParams
return $intMargin < 0 ? 0 : $intMargin;
}
- private static function resolveWriter(array $query): WriterInterface
+ private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
{
- $format = strtolower(trim($query['format'] ?? 'png'));
+ $qFormat = self::normalizeParam($query['format'] ?? '');
+ $format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format());
+
return match ($format) {
'svg' => new SvgWriter(),
default => new PngWriter(),
};
}
- private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
+ private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface
{
- $errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
+ $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection());
return match ($errorCorrectionLevel) {
'h' => new ErrorCorrectionLevelHigh(),
'q' => new ErrorCorrectionLevelQuartile(),
@@ -90,6 +90,11 @@ final class QrCodeParams
};
}
+ private static function normalizeParam(string $param): string
+ {
+ return strtolower(trim($param));
+ }
+
public function size(): int
{
return $this->size;
diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php
index 977e0864..f8d2e275 100644
--- a/module/Core/src/Action/QrCodeAction.php
+++ b/module/Core/src/Action/QrCodeAction.php
@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
+use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
@@ -23,6 +24,7 @@ class QrCodeAction implements MiddlewareInterface
private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger,
+ private QrCodeOptions $defaultOptions,
) {
}
@@ -37,7 +39,7 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request);
}
- $params = QrCodeParams::fromRequest($request);
+ $params = QrCodeParams::fromRequest($request, $this->defaultOptions);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($params->size())
diff --git a/module/Core/src/Options/QrCodeOptions.php b/module/Core/src/Options/QrCodeOptions.php
new file mode 100644
index 00000000..80d6e456
--- /dev/null
+++ b/module/Core/src/Options/QrCodeOptions.php
@@ -0,0 +1,60 @@
+size;
+ }
+
+ protected function setSize(int $size): void
+ {
+ $this->size = $size;
+ }
+
+ public function margin(): int
+ {
+ return $this->margin;
+ }
+
+ protected function setMargin(int $margin): void
+ {
+ $this->margin = $margin;
+ }
+
+ public function format(): string
+ {
+ return $this->format;
+ }
+
+ protected function setFormat(string $format): void
+ {
+ $this->format = $format;
+ }
+
+ public function errorCorrection(): string
+ {
+ return $this->errorCorrection;
+ }
+
+ protected function setErrorCorrection(string $errorCorrection): void
+ {
+ $this->errorCorrection = $errorCorrection;
+ }
+}
diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php
index 0595734e..1fdc35ef 100644
--- a/module/Core/test/Action/QrCodeActionTest.php
+++ b/module/Core/test/Action/QrCodeActionTest.php
@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
+use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
@@ -31,6 +32,7 @@ class QrCodeActionTest extends TestCase
private QrCodeAction $action;
private ObjectProphecy $urlResolver;
+ private QrCodeOptions $options;
public function setUp(): void
{
@@ -38,11 +40,13 @@ class QrCodeActionTest extends TestCase
$router->generateUri(Argument::cetera())->willReturn('/foo/bar');
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
+ $this->options = new QrCodeOptions();
$this->action = new QrCodeAction(
$this->urlResolver->reveal(),
new ShortUrlStringifier(['domain' => 'doma.in']),
new NullLogger(),
+ $this->options,
);
}
@@ -85,9 +89,11 @@ class QrCodeActionTest extends TestCase
* @dataProvider provideQueries
*/
public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat(
+ string $defaultFormat,
array $query,
string $expectedContentType,
): void {
+ $this->options->setFromArray(['format' => $defaultFormat]);
$code = 'abc123';
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
ShortUrl::createEmpty(),
@@ -102,18 +108,26 @@ class QrCodeActionTest extends TestCase
public function provideQueries(): iterable
{
- yield 'no format' => [[], 'image/png'];
- yield 'png format' => [['format' => 'png'], 'image/png'];
- yield 'svg format' => [['format' => 'svg'], 'image/svg+xml'];
- yield 'unsupported format' => [['format' => 'jpg'], 'image/png'];
+ yield 'no format, png default' => ['png', [], 'image/png'];
+ yield 'no format, svg default' => ['svg', [], 'image/svg+xml'];
+ yield 'png format, png default' => ['png', ['format' => 'png'], 'image/png'];
+ yield 'png format, svg default' => ['svg', ['format' => 'png'], 'image/png'];
+ yield 'svg format, png default' => ['png', ['format' => 'svg'], 'image/svg+xml'];
+ yield 'svg format, svg default' => ['svg', ['format' => 'svg'], 'image/svg+xml'];
+ yield 'unsupported format, png default' => ['png', ['format' => 'jpg'], 'image/png'];
+ yield 'unsupported format, svg default' => ['svg', ['format' => 'jpg'], 'image/svg+xml'];
}
/**
* @test
* @dataProvider provideRequestsWithSize
*/
- public function imageIsReturnedWithExpectedSize(ServerRequestInterface $req, int $expectedSize): void
- {
+ public function imageIsReturnedWithExpectedSize(
+ array $defaults,
+ ServerRequestInterface $req,
+ int $expectedSize,
+ ): void {
+ $this->options->setFromArray($defaults);
$code = 'abc123';
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
ShortUrl::createEmpty(),
@@ -128,25 +142,59 @@ class QrCodeActionTest extends TestCase
public function provideRequestsWithSize(): iterable
{
- yield 'no size' => [ServerRequestFactory::fromGlobals(), 300];
- yield 'size in attr' => [ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400];
- yield 'size in query' => [ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123];
+ yield 'different margin and size defaults' => [
+ ['size' => 660, 'margin' => 40],
+ ServerRequestFactory::fromGlobals(),
+ 740,
+ ];
+ yield 'no size' => [[], ServerRequestFactory::fromGlobals(), 300];
+ yield 'no size, different default' => [['size' => 500], ServerRequestFactory::fromGlobals(), 500];
+ yield 'size in attr' => [[], ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400];
+ yield 'size in query' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123];
+ yield 'size in query, default margin' => [
+ ['margin' => 25],
+ ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']),
+ 173,
+ ];
yield 'size in query and attr' => [
+ [],
ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']),
350,
];
- yield 'margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370];
+ yield 'margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370];
+ yield 'margin and different default' => [
+ ['size' => 400],
+ ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']),
+ 470,
+ ];
yield 'margin and size' => [
+ [],
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '100', 'size' => '200']),
400,
];
- yield 'negative margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300];
- yield 'non-numeric margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']), 300];
+ yield 'negative margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300];
+ yield 'negative margin, default margin' => [
+ ['margin' => 10],
+ ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']),
+ 300,
+ ];
+ yield 'non-numeric margin' => [
+ [],
+ ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']),
+ 300,
+ ];
yield 'negative margin and size' => [
+ [],
+ ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']),
+ 150,
+ ];
+ yield 'negative margin and size, default margin' => [
+ ['margin' => 5],
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']),
150,
];
yield 'non-numeric margin and size' => [
+ [],
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo', 'size' => '538']),
538,
];