Compare commits

..

1 Commits

Author SHA1 Message Date
Alejandro Celaya
7bb40c74c1 Merge pull request #895 from shlinkio/develop
Release 2.4.1
2020-11-10 20:00:26 +01:00
37 changed files with 235 additions and 362 deletions

View File

@@ -4,47 +4,6 @@ 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). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
### Added
* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
* [#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.
### Changed
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
### 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 ## [2.4.1] - 2020-11-10
### Added ### Added
* *Nothing* * *Nothing*

View File

@@ -32,12 +32,13 @@
"laminas/laminas-paginator": "^2.8", "laminas/laminas-paginator": "^2.8",
"laminas/laminas-servicemanager": "^3.4", "laminas/laminas-servicemanager": "^3.4",
"laminas/laminas-stdlib": "^3.2", "laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0", "lcobucci/jwt": "^4.0@alpha",
"league/uri": "^6.2", "league/uri": "^6.2",
"lstrojny/functional-php": "^1.9", "lstrojny/functional-php": "^1.9",
"mezzio/mezzio": "^3.2", "mezzio/mezzio": "^3.2",
"mezzio/mezzio-fastroute": "^3.0", "mezzio/mezzio-fastroute": "^3.0",
"mezzio/mezzio-helpers": "^5.3", "mezzio/mezzio-helpers": "^5.3",
"mezzio/mezzio-platesrenderer": "^2.1",
"mezzio/mezzio-problem-details": "^1.1", "mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.6.4", "mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0", "monolog/monolog": "^2.0",
@@ -48,16 +49,16 @@
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^0.5", "pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9", "ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.3.2", "shlinkio/shlink-common": "^3.3.0",
"shlinkio/shlink-config": "^1.0", "shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4", "shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-importer": "^2.0.1", "shlinkio/shlink-importer": "^2.0.1",
"shlinkio/shlink-installer": "^5.2.0", "shlinkio/shlink-installer": "^5.1.0",
"shlinkio/shlink-ip-geolocation": "^1.5", "shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1", "symfony/console": "^5.1",
"symfony/filesystem": "^5.1", "symfony/filesystem": "^5.1",
"symfony/lock": "^5.1", "symfony/lock": "^5.1",
"symfony/mercure": "^0.4.0", "symfony/mercure": "^0.3.0",
"symfony/process": "^5.1", "symfony/process": "^5.1",
"symfony/string": "^5.1" "symfony/string": "^5.1"
}, },

View File

@@ -7,9 +7,6 @@ use Laminas\ConfigAggregator\ConfigAggregator;
return [ return [
'debug' => false, 'debug' => false,
ConfigAggregator::ENABLE_CACHE => true,
// Disabling config cache for cli, ensures it's never used for swoole and also that console commands don't generate
// a cache file that's then used by non-swoole web executions
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',
]; ];

View File

@@ -6,8 +6,8 @@ return [
'geolite2' => [ 'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb', 'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data', 'temp_dir' => sys_get_temp_dir(),
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3 'license_key' => 'G4Lm0C60yJsnkdPi',
], ],
]; ];

View File

@@ -14,7 +14,6 @@ return [
Option\Database\DatabasePortConfigOption::class, Option\Database\DatabasePortConfigOption::class,
Option\Database\DatabaseUserConfigOption::class, Option\Database\DatabaseUserConfigOption::class,
Option\Database\DatabasePasswordConfigOption::class, Option\Database\DatabasePasswordConfigOption::class,
Option\Database\DatabaseUnixSocketConfigOption::class,
Option\Database\DatabaseSqlitePathConfigOption::class, Option\Database\DatabaseSqlitePathConfigOption::class,
Option\Database\DatabaseMySqlOptionsConfigOption::class, Option\Database\DatabaseMySqlOptionsConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class, Option\UrlShortener\ShortDomainHostConfigOption::class,

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
return [
'templates' => [
'extension' => 'phtml',
],
'plates' => [
'extensions' => [
// extension service names or instances
],
],
];

View File

@@ -15,6 +15,7 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class, Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class, Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class,
Mezzio\Plates\ConfigProvider::class,
Mezzio\Swoole\ConfigProvider::class, Mezzio\Swoole\ConfigProvider::class,
ProblemDetails\ConfigProvider::class, ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class, Diactoros\ConfigProvider::class,

View File

@@ -131,7 +131,7 @@ services:
shlink_mercure: shlink_mercure:
container_name: shlink_mercure container_name: shlink_mercure
image: dunglas/mercure:v0.10 image: dunglas/mercure:v0.9
ports: ports:
- "3080:80" - "3080:80"
environment: environment:

View File

@@ -157,7 +157,6 @@ This is the complete list of supported env vars:
* **mysql** or **maria** -> `3306` * **mysql** or **maria** -> `3306`
* **postgres** -> `5432` * **postgres** -> `5432`
* **mssql** -> `1433` * **mssql** -> `1433`
* `DB_UNIX_SOCKET`: Alternatively to the `DB_HOST`, you can provide this to connect through unix sockets when using `mysql`, `maria` or `postgres` drivers.
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided. * `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`. * `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`. * `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
@@ -216,11 +215,7 @@ docker run \
shlinkio/shlink:stable shlinkio/shlink:stable
``` ```
## [DEPRECATED] Provide config via volumes ## Provide config via volumes
> As of v2.5.0, providing config through volumes is deprecated, and no new options will be added anymore. Use env vars instead.
>
> Support for config options through volumes will be removed in Shlink v3.0.0
Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format. Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format.

View File

@@ -34,7 +34,6 @@ $helper = new class {
public function getDbConfig(): array public function getDbConfig(): array
{ {
$driver = env('DB_DRIVER'); $driver = env('DB_DRIVER');
$isMysql = contains(['maria', 'mysql'], $driver);
if ($driver === null || $driver === 'sqlite') { if ($driver === null || $driver === 'sqlite') {
return [ return [
'driver' => 'pdo_sqlite', 'driver' => 'pdo_sqlite',
@@ -42,7 +41,7 @@ $helper = new class {
]; ];
} }
$driverOptions = ! $isMysql ? [] : [ $driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND // 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
1002 => 'SET NAMES utf8', 1002 => 'SET NAMES utf8',
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY // 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
@@ -53,10 +52,9 @@ $helper = new class {
'dbname' => env('DB_NAME', 'shlink'), 'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'), 'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'), 'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null), 'host' => env('DB_HOST'),
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]), 'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
'driverOptions' => $driverOptions, 'driverOptions' => $driverOptions,
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
]; ];
} }
@@ -101,6 +99,8 @@ $helper = new class {
return [ return [
'config_cache_enabled' => false,
'app_options' => [ 'app_options' => [
'disable_track_param' => env('DISABLE_TRACK_PARAM'), 'disable_track_param' => env('DISABLE_TRACK_PARAM'),
], ],

View File

@@ -18,7 +18,7 @@
}, },
{ {
"name": "size", "name": "size",
"in": "query", "in": "path",
"description": "The size of the image to be returned.", "description": "The size of the image to be returned.",
"required": false, "required": false,
"schema": { "schema": {

View File

@@ -1,66 +0,0 @@
{
"get": {
"operationId": "shortUrlQrCodeSize",
"deprecated": true,
"tags": [
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "size",
"in": "path",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
"type": "integer",
"minimum": 50,
"maximum": 1000,
"default": 300
}
},
{
"name": "format",
"in": "query",
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
"required": false,
"schema": {
"type": "string",
"enum": [
"png",
"svg"
]
}
}
],
"responses": {
"200": {
"description": "QR code in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/svg+xml": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}

View File

@@ -50,14 +50,6 @@
"name": "Visits", "name": "Visits",
"description": "Operations to manage visits on short URLs" "description": "Operations to manage visits on short URLs"
}, },
{
"name": "Domains",
"description": "Operations to manage domains used on short URLs"
},
{
"name": "Integrations",
"description": "Handle services with which shlink is integrated"
},
{ {
"name": "Monitoring", "name": "Monitoring",
"description": "Public endpoints designed to monitor the service" "description": "Public endpoints designed to monitor the service"
@@ -116,9 +108,6 @@
}, },
"/{shortCode}/qr-code": { "/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json" "$ref": "paths/{shortCode}_qr-code.json"
},
"/{shortCode}/qr-code/{size}": {
"$ref": "paths/{shortCode}_qr-code_{size}.json"
} }
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI; namespace ShlinkioTest\Shlink\CLI;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ConfigProvider; use Shlinkio\Shlink\CLI\ConfigProvider;
@@ -22,9 +21,7 @@ class ConfigProviderTest extends TestCase
{ {
$config = ($this->configProvider)(); $config = ($this->configProvider)();
self::assertCount(3, $config);
self::assertArrayHasKey('cli', $config); self::assertArrayHasKey('cli', $config);
self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
} }
} }

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core; namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Template\TemplateRendererInterface;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
@@ -16,7 +16,7 @@ return [
'dependencies' => [ 'dependencies' => [
'factories' => [ 'factories' => [
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class, ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class, ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class, Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
@@ -60,6 +60,7 @@ return [
Util\RedirectResponseHelper::class, Util\RedirectResponseHelper::class,
'config.router.base_path', 'config.router.base_path',
], ],
ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
Options\AppOptions::class => ['config.app_options'], Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'mezzio' => [
'error_handler' => [
'template_404' => 'ShlinkCore::error/404',
'template_error' => 'ShlinkCore::error/error',
],
],
];

View File

@@ -29,17 +29,7 @@ return [
], ],
[ [
'name' => Action\QrCodeAction::class, 'name' => Action\QrCodeAction::class,
'path' => '/{shortCode}/qr-code', 'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
'middleware' => [
Action\QrCodeAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
// Deprecated
[
'name' => 'old_' . Action\QrCodeAction::class,
'path' => '/{shortCode}/qr-code/{size:[0-9]+}',
'middleware' => [ 'middleware' => [
Action\QrCodeAction::class, Action\QrCodeAction::class,
], ],

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'templates' => [
'paths' => [
'ShlinkCore' => __DIR__ . '/../templates',
],
],
];

View File

@@ -41,7 +41,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
$this->urlResolver = $urlResolver; $this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker; $this->visitTracker = $visitTracker;
$this->appOptions = $appOptions; $this->appOptions = $appOptions;
$this->logger = $logger ?? new NullLogger(); $this->logger = $logger ?: new NullLogger();
} }
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

View File

@@ -34,7 +34,7 @@ class QrCodeAction implements MiddlewareInterface
) { ) {
$this->urlResolver = $urlResolver; $this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig; $this->domainConfig = $domainConfig;
$this->logger = $logger ?? new NullLogger(); $this->logger = $logger ?: new NullLogger();
} }
public function process(Request $request, RequestHandlerInterface $handler): Response public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -48,15 +48,11 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request); return $handler->handle($request);
} }
$query = $request->getQueryParams();
// Size attribute is deprecated
$size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE));
$qrCode = new QrCode($shortUrl->toString($this->domainConfig)); $qrCode = new QrCode($shortUrl->toString($this->domainConfig));
$qrCode->setSize($size); $qrCode->setSize($this->getSizeParam($request));
$qrCode->setMargin(0); $qrCode->setMargin(0);
$format = $query['format'] ?? 'png'; $format = $request->getQueryParams()['format'] ?? 'png';
if ($format === 'svg') { if ($format === 'svg') {
$qrCode->setWriter(new SvgWriter()); $qrCode->setWriter(new SvgWriter());
} }
@@ -64,8 +60,9 @@ class QrCodeAction implements MiddlewareInterface
return new QrCodeResponse($qrCode); return new QrCodeResponse($qrCode);
} }
private function normalizeSize(int $size): int private function getSizeParam(Request $request): int
{ {
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) { if ($size < self::MIN_SIZE) {
return self::MIN_SIZE; return self::MIN_SIZE;
} }

View File

@@ -15,7 +15,6 @@ use function Functional\contains;
use function Functional\reduce_left; use function Functional\reduce_left;
use function uksort; use function uksort;
/** @deprecated */
class SimplifiedConfigParser class SimplifiedConfigParser
{ {
private const SIMPLIFIED_CONFIG_MAPPING = [ private const SIMPLIFIED_CONFIG_MAPPING = [

View File

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository; namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
class DomainRepository extends EntityRepository implements DomainRepositoryInterface class DomainRepository extends EntityRepository implements DomainRepositoryInterface
{ {
@@ -16,9 +14,7 @@ class DomainRepository extends EntityRepository implements DomainRepositoryInter
*/ */
public function findDomainsWithout(?string $excludedAuthority = null): array public function findDomainsWithout(?string $excludedAuthority = null): array
{ {
$qb = $this->createQueryBuilder('d'); $qb = $this->createQueryBuilder('d')->orderBy('d.authority', 'ASC');
$qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d')
->orderBy('d.authority', 'ASC');
if ($excludedAuthority !== null) { if ($excludedAuthority !== null) {
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority')) $qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))

View File

@@ -4,37 +4,40 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler; namespace Shlinkio\Shlink\Core\ErrorHandler;
use Closure;
use Fig\Http\Message\StatusCodeInterface; use Fig\Http\Message\StatusCodeInterface;
use InvalidArgumentException;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Mezzio\Router\RouteResult; use Mezzio\Router\RouteResult;
use Mezzio\Template\TemplateRendererInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use function file_get_contents;
use function sprintf;
class NotFoundTemplateHandler implements RequestHandlerInterface class NotFoundTemplateHandler implements RequestHandlerInterface
{ {
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates'; public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
public const NOT_FOUND_TEMPLATE = '404.html'; public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
private Closure $readFile;
public function __construct(?callable $readFile = null) private TemplateRendererInterface $renderer;
public function __construct(TemplateRendererInterface $renderer)
{ {
$this->readFile = $readFile ? Closure::fromCallable($readFile) : fn (string $file) => file_get_contents($file); $this->renderer = $renderer;
} }
/**
* Dispatch the next available middleware and return the response.
*
*
* @throws InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface public function handle(ServerRequestInterface $request): ResponseInterface
{ {
/** @var RouteResult $routeResult */ /** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null); $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$status = StatusCodeInterface::STATUS_NOT_FOUND; $status = StatusCodeInterface::STATUS_NOT_FOUND;
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE; $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
$templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template)); return new Response\HtmlResponse($this->renderer->render($template), $status);
return new Response\HtmlResponse($templateContent, $status);
} }
} }

View File

@@ -19,6 +19,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params = $params; $this->params = $params;
} }
/**
* Returns a collection of items for a page.
*
* @param int $offset Page offset
* @param int $itemCountPerPage Number of items per page
*/
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
{ {
return $this->repository->findList( return $this->repository->findList(
@@ -31,6 +37,15 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
); );
} }
/**
* Count elements of an object
* @link http://php.net/manual/en/countable.count.php
* @return int The custom count as an integer.
* </p>
* <p>
* The return value is cast to an integer.
* @since 5.1.0
*/
public function count(): int public function count(): int
{ {
return $this->repository->countList( return $this->repository->countList(

View File

@@ -33,9 +33,15 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
?DateRange $dateRange = null ?DateRange $dateRange = null
): array { ): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
$qb->select('DISTINCT s') $qb->select('DISTINCT s');
->setMaxResults($limit)
->setFirstResult($offset); // Set limit and offset
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
// In case the ordering has been specified, the query could be more complex. Process it // In case the ordering has been specified, the query could be more complex. Process it
if ($orderBy !== null && $orderBy->hasOrderField()) { if ($orderBy !== null && $orderBy->hasOrderField()) {
@@ -141,7 +147,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
WHERE s.shortCode = :shortCode WHERE s.shortCode = :shortCode
AND (s.domain IS NULL OR d.authority = :domain) AND (s.domain IS NULL OR d.authority = :domain)
ORDER BY s.domain {$ordering} ORDER BY s.domain {$ordering}
DQL; DQL;
$query = $this->getEntityManager()->createQuery($dql); $query = $this->getEntityManager()->createQuery($dql);
$query->setMaxResults(1) $query->setMaxResults(1)
@@ -214,8 +220,9 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
} }
if ($meta->hasValidUntil()) { if ($meta->hasValidUntil()) {
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil')) $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
->setParameter('validUntil', $meta->getValidUntil()); ->setParameter('validUntil', $meta->getValidUntil());
} }
if ($meta->hasDomain()) { if ($meta->hasDomain()) {
$qb->join('s.domain', 'd') $qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':domain')) ->andWhere($qb->expr()->eq('d.authority', ':domain'))

View File

@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Not Found | Shlink</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="shortcut icon" href="/favicon.ico">
<style>
html, body {height: 100%}
.app {height: 100vh; display: flex; align-items: center; justify-content: center; flex-flow: column;}
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
</head>
<body>
<div class="app">
<main class="container">
<h1>404</h1>
<hr>
<h3>Page not found.</h3>
<p>The page you requested could not be found.</p>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<?php $this->layout('ShlinkCore::layout/default') ?>
<?php $this->start('title') ?>
Not Found
<?php $this->end() ?>
<?php $this->start('stylesheets') ?>
<style>
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
<?php $this->end() ?>
<?php $this->start('main') ?>
<h1>404</h1>
<hr>
<h3>Page not found.</h3>
<p>The page you requested could not be found.</p>
<?php $this->end() ?>

View File

@@ -0,0 +1,25 @@
<?php $this->layout('ShlinkCore::layout/default') ?>
<?php $this->start('title') ?>
<?= $this->e($status . ' ' . $reason) ?>
<?php $this->end() ?>
<?php $this->start('stylesheets') ?>
<style>
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
<?php $this->end() ?>
<?php $this->start('main') ?>
<h1>Oops!</h1>
<hr>
<?php if ($status !== 404): ?>
<p><?= sprintf('We encountered a %s %s error.', $status, $reason) ?></p>
<?php else: ?>
<p>'This short URL doesn't seem to be valid.</p>
<p>'Make sure you included all the characters, with no extra punctuation.</p>
<?php endif; ?>
<?php $this->end() ?>

View File

@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Invalid Short URL | Shlink</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="shortcut icon" href="/favicon.ico">
<style>
html, body {height: 100%}
.app {height: 100vh; display: flex; align-items: center; justify-content: center; flex-flow: column;}
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
</head>
<body>
<div class="app">
<main class="container">
<h1>Oops!</h1>
<hr>
<p>This short URL doesn't seem to be valid.</p>
<p>Make sure you included all the characters, with no extra punctuation.</p>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<?php $this->layout('ShlinkCore::layout/default') ?>
<?php $this->start('title') ?>
Invalid Short URL
<?php $this->end() ?>
<?php $this->start('stylesheets') ?>
<style>
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
<?php $this->end() ?>
<?php $this->start('main') ?>
<h1>Oops!</h1>
<hr>
<p>This short URL doesn't seem to be valid.</p>
<p>Make sure you included all the characters, with no extra punctuation.</p>
<?php $this->end() ?>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title><?= $this->section('title', '') ?> | Shlink</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="shortcut icon" href="/favicon.ico">
<style>
html, body {height: 100%}
.app {height: 100vh; display: flex; align-items: center; justify-content: center; flex-flow: column;}
</style>
<?= $this->section('stylesheets', '') ?>
</head>
<body>
<div class="app">
<main class="container">
<?= $this->section('main', '') ?>
</main>
</div>
</body>
</html>

View File

@@ -6,15 +6,11 @@ namespace ShlinkioTest\Shlink\Core\Domain\Repository;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
class DomainRepositoryTest extends DatabaseTestCase class DomainRepositoryTest extends DatabaseTestCase
{ {
protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class]; protected const ENTITIES_TO_EMPTY = [Domain::class];
private DomainRepository $repo; private DomainRepository $repo;
@@ -27,23 +23,12 @@ class DomainRepositoryTest extends DatabaseTestCase
public function findDomainsReturnsExpectedResult(): void public function findDomainsReturnsExpectedResult(): void
{ {
$fooDomain = new Domain('foo.com'); $fooDomain = new Domain('foo.com');
$this->getEntityManager()->persist($fooDomain);
$fooShortUrl = $this->createShortUrl($fooDomain);
$this->getEntityManager()->persist($fooShortUrl);
$barDomain = new Domain('bar.com'); $barDomain = new Domain('bar.com');
$this->getEntityManager()->persist($barDomain);
$barShortUrl = $this->createShortUrl($barDomain);
$this->getEntityManager()->persist($barShortUrl);
$bazDomain = new Domain('baz.com'); $bazDomain = new Domain('baz.com');
$this->getEntityManager()->persist($fooDomain);
$this->getEntityManager()->persist($barDomain);
$this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($bazDomain);
$bazShortUrl = $this->createShortUrl($bazDomain);
$this->getEntityManager()->persist($bazShortUrl);
$detachedDomain = new Domain('detached.com');
$this->getEntityManager()->persist($detachedDomain);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout()); self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout());
@@ -51,30 +36,4 @@ class DomainRepositoryTest extends DatabaseTestCase
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com'));
self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com'));
} }
private function createShortUrl(Domain $domain): ShortUrl
{
return new ShortUrl(
'foo',
ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]),
new class ($domain) implements ShortUrlRelationResolverInterface {
private Domain $domain;
public function __construct(Domain $domain)
{
$this->domain = $domain;
}
public function resolveDomain(?string $domain): ?Domain
{
return $this->domain;
}
public function resolveApiKey(?string $key): ?ApiKey
{
return null;
}
},
);
}
} }

View File

@@ -6,13 +6,11 @@ namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\Router\RouterInterface; use Mezzio\Router\RouterInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Action\QrCodeAction;
@@ -21,8 +19,6 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use function getimagesizefromstring;
class QrCodeActionTest extends TestCase class QrCodeActionTest extends TestCase
{ {
use ProphecyTrait; use ProphecyTrait;
@@ -55,6 +51,21 @@ class QrCodeActionTest extends TestCase
$process->shouldHaveBeenCalledOnce(); $process->shouldHaveBeenCalledOnce();
} }
/** @test */
public function anInvalidShortCodeWillReturnNotFoundResponse(): void
{
$shortCode = 'abc123';
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
$this->action->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal());
$process->shouldHaveBeenCalledOnce();
}
/** @test */ /** @test */
public function aCorrectRequestReturnsTheQrCodeResponse(): void public function aCorrectRequestReturnsTheQrCodeResponse(): void
{ {
@@ -99,31 +110,4 @@ class QrCodeActionTest extends TestCase
yield 'svg format' => [['format' => 'svg'], 'image/svg+xml']; yield 'svg format' => [['format' => 'svg'], 'image/svg+xml'];
yield 'unsupported format' => [['format' => 'jpg'], 'image/png']; yield 'unsupported format' => [['format' => 'jpg'], 'image/png'];
} }
/**
* @test
* @dataProvider provideRequestsWithSize
*/
public function imageIsReturnedWithExpectedSize(ServerRequestInterface $req, int $expectedSize): void
{
$code = 'abc123';
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl(''));
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal());
[$size] = getimagesizefromstring((string) $resp->getBody());
self::assertEquals($expectedSize, $size);
}
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 'size in query and attr' => [
ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']),
350,
];
}
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core; namespace ShlinkioTest\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\ConfigProvider; use Shlinkio\Shlink\Core\ConfigProvider;
@@ -20,13 +19,11 @@ class ConfigProviderTest extends TestCase
/** @test */ /** @test */
public function properConfigIsReturned(): void public function properConfigIsReturned(): void
{ {
$config = ($this->configProvider)(); $config = $this->configProvider->__invoke();
self::assertCount(5, $config);
self::assertArrayHasKey('routes', $config); self::assertArrayHasKey('routes', $config);
self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey('entity_manager', $config); self::assertArrayHasKey('templates', $config);
self::assertArrayHasKey('events', $config); self::assertArrayHasKey('mezzio', $config);
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
} }
} }

View File

@@ -4,30 +4,29 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ErrorHandler; namespace ShlinkioTest\Shlink\Core\ErrorHandler;
use Closure;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\Router\Route; use Mezzio\Router\Route;
use Mezzio\Router\RouteResult; use Mezzio\Router\RouteResult;
use Mezzio\Template\TemplateRendererInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler;
class NotFoundTemplateHandlerTest extends TestCase class NotFoundTemplateHandlerTest extends TestCase
{ {
use ProphecyTrait;
private NotFoundTemplateHandler $handler; private NotFoundTemplateHandler $handler;
private Closure $readFile; private ObjectProphecy $renderer;
private bool $readFileCalled;
public function setUp(): void public function setUp(): void
{ {
$this->readFileCalled = false; $this->renderer = $this->prophesize(TemplateRendererInterface::class);
$this->readFile = function (string $fileName): string { $this->handler = new NotFoundTemplateHandler($this->renderer->reveal());
$this->readFileCalled = true;
return $fileName;
};
$this->handler = new NotFoundTemplateHandler($this->readFile);
} }
/** /**
@@ -36,11 +35,13 @@ class NotFoundTemplateHandlerTest extends TestCase
*/ */
public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void
{ {
$resp = $this->handler->handle($request->withHeader('Accept', 'text/html')); $request = $request->withHeader('Accept', 'text/html');
$render = $this->renderer->render($expectedTemplate)->willReturn('');
$resp = $this->handler->handle($request);
self::assertInstanceOf(Response\HtmlResponse::class, $resp); self::assertInstanceOf(Response\HtmlResponse::class, $resp);
self::assertStringContainsString($expectedTemplate, (string) $resp->getBody()); $render->shouldHaveBeenCalledOnce();
self::assertTrue($this->readFileCalled);
} }
public function provideTemplates(): iterable public function provideTemplates(): iterable

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Fixtures;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Persistence\ObjectManager;
use Shlinkio\Shlink\Core\Entity\Domain;
class DomainFixture extends AbstractFixture
{
public function load(ObjectManager $manager): void
{
$orphanDomain = new Domain('this_domain_is_detached.com');
$manager->persist($orphanDomain);
$manager->flush();
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest; namespace ShlinkioTest\Shlink\Rest;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Rest\ConfigProvider; use Shlinkio\Shlink\Rest\ConfigProvider;
@@ -22,12 +21,8 @@ class ConfigProviderTest extends TestCase
{ {
$config = ($this->configProvider)(); $config = ($this->configProvider)();
self::assertCount(5, $config);
self::assertArrayHasKey('routes', $config); self::assertArrayHasKey('routes', $config);
self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey('auth', $config);
self::assertArrayHasKey('entity_manager', $config);
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
} }
/** /**