Added new config to set custom defaults for QR codes

This commit is contained in:
Alejandro Celaya 2021-09-26 13:25:02 +02:00
parent cbec4a4e81
commit 6a1ee2b894
7 changed files with 174 additions and 31 deletions

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
'qr_codes' => [
'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),
],
];

View file

@ -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[^>]*>(.*?)<\/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';

View file

@ -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],

View file

@ -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;

View file

@ -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())

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
class QrCodeOptions extends AbstractOptions
{
private int $size = DEFAULT_QR_CODE_SIZE;
private int $margin = DEFAULT_QR_CODE_MARGIN;
private string $format = DEFAULT_QR_CODE_FORMAT;
private string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION;
public function size(): int
{
return $this->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;
}
}

View file

@ -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,
];