Compare commits

...

45 Commits

Author SHA1 Message Date
Alejandro Celaya
179ddc5bd7 Merge pull request #925 from acelaya-forks/feature/db-socket-connection
Feature/db socket connection
2020-11-29 20:08:51 +01:00
Alejandro Celaya
bfd886604e Updated changelog 2020-11-29 19:50:39 +01:00
Alejandro Celaya
f34033aa9c Documented how to provide the unix socket to connect to mysql, maria and postgres databases 2020-11-29 19:46:34 +01:00
Alejandro Celaya
e54745b250 #833 Enabled unix socket option during installation 2020-11-29 14:01:26 +01:00
Alejandro Celaya
1975a35837 Updated to lcobucci/json 4.0 stable 2020-11-29 12:54:22 +01:00
Alejandro Celaya
5db66dcf0e Merge pull request #923 from acelaya-forks/feature/qr-codes-query-size
Feature/qr codes query size
2020-11-27 18:00:01 +01:00
Alejandro Celaya
cfdf2f9480 #917 Updated changelog 2020-11-27 17:50:09 +01:00
Alejandro Celaya
c13adb04ef #917 Documented QR endpoint with query size and path size 2020-11-27 17:47:52 +01:00
Alejandro Celaya
4f1ab977a1 #917 Added tests covering the different ways to provide sizes to the QR codes 2020-11-27 17:42:33 +01:00
Alejandro Celaya
fe59a5ad86 #917 Fixed cast to int on QR code action 2020-11-27 17:16:54 +01:00
Alejandro Celaya
a72dc16d85 #917 2020-11-27 17:05:13 +01:00
Alejandro Celaya
74108a19e5 Merge pull request #915 from acelaya-forks/feature/remove-plates
Feature/remove plates
2020-11-22 18:42:19 +01:00
Alejandro Celaya
abe0fc16df #912 Updated changelog 2020-11-22 18:13:12 +01:00
Alejandro Celaya
39bda5113b #912 Fixed unit tests 2020-11-22 18:11:31 +01:00
Alejandro Celaya
49ea5cc78b #912 Removed dependency on league/plates 2020-11-22 18:03:27 +01:00
Alejandro Celaya
8acde332b2 Merge pull request #914 from acelaya-forks/feature/mercure-10-compat
Feature/mercure 10 compat
2020-11-22 16:41:26 +01:00
Alejandro Celaya
600f7a7388 #869 Updated changelog 2020-11-22 16:27:24 +01:00
Alejandro Celaya
fd007ea4a9 #869 Updated dependencies to support mercure 0.10 2020-11-22 16:26:17 +01:00
Alejandro Celaya
b66922b3d5 Ensured lcobucci/jwt stays in alpha 2020-11-22 10:44:13 +01:00
Alejandro Celaya
7d981434e1 Merge pull request #910 from acelaya-forks/feature/swoole-bug
Feature/swoole bug
2020-11-22 10:41:10 +01:00
Alejandro Celaya
c672d35b4a #827 Updated changelog 2020-11-22 10:26:18 +01:00
Alejandro Celaya
6259c73b33 #827 Fixed swoole config getting loaded on non-swoole contexts when running CLI command first 2020-11-22 10:24:06 +01:00
Alejandro Celaya
e4b00e832a Merge pull request #909 from acelaya-forks/feature/geolite-temp-dir
Feature/geolite temp dir
2020-11-21 12:48:28 +01:00
Alejandro Celaya
a452aeaf7e #899 Updated changelog 2020-11-21 12:38:14 +01:00
Alejandro Celaya
6e83b90028 #899 Changed temp directory in which geolite DB files are downloaded 2020-11-21 12:36:30 +01:00
Alejandro Celaya
45ffdce312 Merge pull request #908 from acelaya-forks/feature/domains-list
Feature/domains list
2020-11-21 09:46:16 +01:00
Alejandro Celaya
5485efc9ae #901 Fixed condition type 2020-11-21 08:51:30 +01:00
Alejandro Celaya
850360dd2b #901 Updated changelog 2020-11-21 08:45:57 +01:00
Alejandro Celaya
8d3ceaf462 #901 Ensured only domains in use are returned to lists 2020-11-21 08:44:28 +01:00
Alejandro Celaya
bb6c5de697 Merge pull request #907 from acelaya-forks/feature/missing-swagger-info
Feature/missing swagger info
2020-11-21 08:18:14 +01:00
Alejandro Celaya
ca4c1b00dc #904 Updated changelog 2020-11-21 08:16:22 +01:00
Alejandro Celaya
dda6d30c12 #904 Explicitly added missing Domains and Integrations tags to swagger docs 2020-11-21 08:13:29 +01:00
Alejandro Celaya
4515a83e9b Fixed github action syntax 2020-11-10 19:06:50 +01:00
Alejandro Celaya
907a282b73 Merge pull request #894 from acelaya-forks/feature/docker-publish-action
Feature/docker publish action
2020-11-10 19:05:24 +01:00
Alejandro Celaya
5154638ddf Added v2.4.1 to changelog 2020-11-10 19:04:08 +01:00
Alejandro Celaya
52c9994eb4 #890 Migrated to official docker actions for docker-image-build workflow 2020-11-10 19:03:14 +01:00
Alejandro Celaya
912f287a27 Merge pull request #893 from acelaya-forks/feature/wrong-redirect-status
Feature/wrong redirect status
2020-11-10 18:59:09 +01:00
Alejandro Celaya
e99ab66afd Updated changelog 2020-11-10 18:33:33 +01:00
Alejandro Celaya
fb022eae68 #867 Changed use of deprecated functions by their replacements 2020-11-10 18:13:24 +01:00
Alejandro Celaya
259c52a698 #867 Ensured status code config is honored when doing not-found redirects 2020-11-10 18:08:25 +01:00
Alejandro Celaya
deeca582db #867 Small refactoring on NotFoundRedirecthandler 2020-11-10 17:30:14 +01:00
Alejandro Celaya
4dbcf6857e Merge pull request #892 from acelaya-forks/feature/fix-typehint
Feature/fix typehint
2020-11-10 17:28:10 +01:00
Alejandro Celaya
5190a03113 #846 Fixed base image used for PHP-FPM dev container 2020-11-10 16:08:22 +01:00
Alejandro Celaya
d60c3a4aa9 #891 Updated changelog 2020-11-10 15:51:04 +01:00
Alejandro Celaya
ce1c70fd7c #891 Fixed wrong return type hint on method inside migration when using postgres 2020-11-10 15:49:05 +01:00
48 changed files with 554 additions and 315 deletions

View File

@@ -13,12 +13,16 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
buildx-version: latest
version: latest
- name: Login to docker hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build the image
run: bash ./docker/build

View File

@@ -4,6 +4,66 @@ 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).
## [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
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#891](https://github.com/shlinkio/shlink/issues/891) Fixed error when running migrations in postgres due to incorrect return type hint.
* [#846](https://github.com/shlinkio/shlink/issues/846) Fixed base image used for the PHP-FPM dev container.
* [#867](https://github.com/shlinkio/shlink/issues/867) Fixed not-found redirects not using proper status (301 or 302) as configured during installation.
## [2.4.0] - 2020-11-08
### Added
* [#829](https://github.com/shlinkio/shlink/issues/829) Added support for QR codes in SVG format, by passing `?format=svg` to the QR code URL.

View File

@@ -1,6 +1,6 @@
FROM php:7.4.11-alpine3.12 as base
ARG SHLINK_VERSION=2.3.0
ARG SHLINK_VERSION=2.4.0
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.5.5
ENV LC_ALL "C"

View File

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

View File

@@ -7,6 +7,9 @@ use Laminas\ConfigAggregator\ConfigAggregator;
return [
'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' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => sys_get_temp_dir(),
'license_key' => 'G4Lm0C60yJsnkdPi',
'temp_dir' => __DIR__ . '/../../data',
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3
],
];

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
FROM php:7.4.11-alpine3.12
FROM php:7.4.11-fpm-alpine3.12
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18

View File

@@ -60,7 +60,10 @@ final class Version20201102113208 extends AbstractMigration
->execute();
}
private function resolveOneApiKeyId(Result $result): ?string
/**
* @return string|int|null
*/
private function resolveOneApiKeyId(Result $result)
{
$results = [];
while ($row = $result->fetchAssociative()) {

View File

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

View File

@@ -157,6 +157,7 @@ This is the complete list of supported env vars:
* **mysql** or **maria** -> `3306`
* **postgres** -> `5432`
* **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.
* `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`.
@@ -215,7 +216,11 @@ docker run \
shlinkio/shlink:stable
```
## Provide config via volumes
## [DEPRECATED] 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.

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
{
"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,6 +50,14 @@
"name": "Visits",
"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",
"description": "Public endpoints designed to monitor the service"
@@ -108,6 +116,9 @@
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
},
"/{shortCode}/qr-code/{size}": {
"$ref": "paths/{shortCode}_qr-code_{size}.json"
}
}
}

View File

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

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Mezzio\Template\TemplateRendererInterface;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
@@ -16,7 +16,7 @@ return [
'dependencies' => [
'factories' => [
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
@@ -36,6 +36,7 @@ return [
Util\UrlValidator::class => ConfigAbstractFactory::class,
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
@@ -54,8 +55,11 @@ return [
],
ConfigAbstractFactory::class => [
ErrorHandler\NotFoundRedirectHandler::class => [NotFoundRedirectOptions::class, 'config.router.base_path'],
ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
ErrorHandler\NotFoundRedirectHandler::class => [
NotFoundRedirectOptions::class,
Util\RedirectResponseHelper::class,
'config.router.base_path',
],
Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
@@ -88,12 +92,13 @@ return [
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
Options\UrlShortenerOptions::class,
Util\RedirectResponseHelper::class,
'Logger_Shlink',
],
Action\PixelAction::class => [

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\Psr7\Query;
use League\Uri\Uri;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ResponseInterface;
@@ -23,8 +24,6 @@ use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use function array_key_exists;
use function array_merge;
use function GuzzleHttp\Psr7\build_query;
use function GuzzleHttp\Psr7\parse_query;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
@@ -42,7 +41,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
$this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
$this->logger = $logger ?: new NullLogger();
$this->logger = $logger ?? new NullLogger();
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -68,13 +67,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
{
$uri = Uri::createFromString($shortUrl->getLongUrl());
$hardcodedQuery = parse_query($uri->getQuery() ?? '');
$hardcodedQuery = Query::parse($uri->getQuery() ?? '');
if ($disableTrackParam !== null) {
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(build_query($mergedQuery)));
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(Query::build($mergedQuery)));
}
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
@@ -13,32 +12,26 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use function sprintf;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
{
private Options\UrlShortenerOptions $urlShortenerOptions;
private RedirectResponseHelperInterface $redirectResponseHelper;
public function __construct(
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
Options\AppOptions $appOptions,
Options\UrlShortenerOptions $urlShortenerOptions,
RedirectResponseHelperInterface $redirectResponseHelper,
?LoggerInterface $logger = null
) {
parent::__construct($urlResolver, $visitTracker, $appOptions, $logger);
$this->urlShortenerOptions = $urlShortenerOptions;
$this->redirectResponseHelper = $redirectResponseHelper;
}
protected function createSuccessResp(string $longUrl): Response
{
$statusCode = $this->urlShortenerOptions->redirectStatusCode();
$headers = $statusCode === self::STATUS_FOUND ? [] : [
'Cache-Control' => sprintf('private,max-age=%s', $this->urlShortenerOptions->redirectCacheLifetime()),
];
return new RedirectResponse($longUrl, $statusCode, $headers);
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
}
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler;
use Laminas\Diactoros\Response;
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -12,19 +11,25 @@ use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function rtrim;
class NotFoundRedirectHandler implements MiddlewareInterface
{
private NotFoundRedirectOptions $redirectOptions;
private Options\NotFoundRedirectOptions $redirectOptions;
private RedirectResponseHelperInterface $redirectResponseHelper;
private string $shlinkBasePath;
public function __construct(NotFoundRedirectOptions $redirectOptions, string $shlinkBasePath)
{
public function __construct(
Options\NotFoundRedirectOptions $redirectOptions,
RedirectResponseHelperInterface $redirectResponseHelper,
string $shlinkBasePath
) {
$this->redirectOptions = $redirectOptions;
$this->shlinkBasePath = $shlinkBasePath;
$this->redirectResponseHelper = $redirectResponseHelper;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -41,11 +46,13 @@ class NotFoundRedirectHandler implements MiddlewareInterface
$isBaseUrl = rtrim($uri->getPath(), '/') === $this->shlinkBasePath;
if ($isBaseUrl && $this->redirectOptions->hasBaseUrlRedirect()) {
return new Response\RedirectResponse($this->redirectOptions->getBaseUrlRedirect());
return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
}
if (!$isBaseUrl && $routeResult->isFailure() && $this->redirectOptions->hasRegular404Redirect()) {
return new Response\RedirectResponse($this->redirectOptions->getRegular404Redirect());
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getRegular404Redirect(),
);
}
if (
@@ -53,7 +60,9 @@ class NotFoundRedirectHandler implements MiddlewareInterface
$routeResult->getMatchedRouteName() === RedirectAction::class &&
$this->redirectOptions->hasInvalidShortUrlRedirect()
) {
return new Response\RedirectResponse($this->redirectOptions->getInvalidShortUrlRedirect());
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getInvalidShortUrlRedirect(),
);
}
return null;

View File

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

View File

@@ -19,12 +19,6 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$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
{
return $this->repository->findList(
@@ -37,15 +31,6 @@ 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
{
return $this->repository->countList(

View File

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

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use function sprintf;
class RedirectResponseHelper implements RedirectResponseHelperInterface
{
private UrlShortenerOptions $options;
public function __construct(UrlShortenerOptions $options)
{
$this->options = $options;
}
public function buildRedirectResponse(string $location): ResponseInterface
{
$statusCode = $this->options->redirectStatusCode();
$headers = $statusCode === StatusCodeInterface::STATUS_FOUND ? [] : [
'Cache-Control' => sprintf('private,max-age=%s', $this->options->redirectCacheLifetime()),
];
return new RedirectResponse($location, $statusCode, $headers);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Psr\Http\Message\ResponseInterface;
interface RedirectResponseHelperInterface
{
public function buildRedirectResponse(string $location): ResponseInterface;
}

View File

@@ -0,0 +1,27 @@
<!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

@@ -1,19 +0,0 @@
<?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

@@ -1,25 +0,0 @@
<?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

@@ -0,0 +1,27 @@
<!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

@@ -1,19 +0,0 @@
<?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

@@ -1,23 +0,0 @@
<!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,11 +6,15 @@ namespace ShlinkioTest\Shlink\Core\Domain\Repository;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
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;
class DomainRepositoryTest extends DatabaseTestCase
{
protected const ENTITIES_TO_EMPTY = [Domain::class];
protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class];
private DomainRepository $repo;
@@ -23,12 +27,23 @@ class DomainRepositoryTest extends DatabaseTestCase
public function findDomainsReturnsExpectedResult(): void
{
$fooDomain = new Domain('foo.com');
$barDomain = new Domain('bar.com');
$bazDomain = new Domain('baz.com');
$this->getEntityManager()->persist($fooDomain);
$fooShortUrl = $this->createShortUrl($fooDomain);
$this->getEntityManager()->persist($fooShortUrl);
$barDomain = new Domain('bar.com');
$this->getEntityManager()->persist($barDomain);
$barShortUrl = $this->createShortUrl($barDomain);
$this->getEntityManager()->persist($barShortUrl);
$bazDomain = new Domain('baz.com');
$this->getEntityManager()->persist($bazDomain);
$bazShortUrl = $this->createShortUrl($bazDomain);
$this->getEntityManager()->persist($bazShortUrl);
$detachedDomain = new Domain('detached.com');
$this->getEntityManager()->persist($detachedDomain);
$this->getEntityManager()->flush();
self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout());
@@ -36,4 +51,30 @@ class DomainRepositoryTest extends DatabaseTestCase
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.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,11 +6,13 @@ namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\Router\RouterInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction;
@@ -19,6 +21,8 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use function getimagesizefromstring;
class QrCodeActionTest extends TestCase
{
use ProphecyTrait;
@@ -51,21 +55,6 @@ class QrCodeActionTest extends TestCase
$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 */
public function aCorrectRequestReturnsTheQrCodeResponse(): void
{
@@ -110,4 +99,31 @@ class QrCodeActionTest extends TestCase
yield 'svg format' => [['format' => 'svg'], 'image/svg+xml'];
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

@@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function array_key_exists;
@@ -30,19 +31,19 @@ class RedirectActionTest extends TestCase
private RedirectAction $action;
private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker;
private Options\UrlShortenerOptions $shortenerOpts;
private ObjectProphecy $redirectRespHelper;
public function setUp(): void
{
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->visitTracker = $this->prophesize(VisitsTrackerInterface::class);
$this->shortenerOpts = new Options\UrlShortenerOptions();
$this->redirectRespHelper = $this->prophesize(RedirectResponseHelperInterface::class);
$this->action = new RedirectAction(
$this->urlResolver->reveal(),
$this->visitTracker->reveal(),
new Options\AppOptions(['disableTrackParam' => 'foobar']),
$this->shortenerOpts,
$this->redirectRespHelper->reveal(),
);
}
@@ -59,14 +60,14 @@ class RedirectActionTest extends TestCase
)->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
});
$expectedResp = new Response\RedirectResponse($expectedUrl);
$buildResp = $this->redirectRespHelper->buildRedirectResponse($expectedUrl)->willReturn($expectedResp);
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
self::assertInstanceOf(Response\RedirectResponse::class, $response);
self::assertEquals(302, $response->getStatusCode());
self::assertTrue($response->hasHeader('Location'));
self::assertEquals($expectedUrl, $response->getHeaderLine('Location'));
self::assertSame($expectedResp, $response);
$buildResp->shouldHaveBeenCalledOnce();
$shortCodeToUrl->shouldHaveBeenCalledOnce();
$track->shouldHaveBeenCalledTimes(array_key_exists('foobar', $query) ? 0 : 1);
}
@@ -107,6 +108,9 @@ class RedirectActionTest extends TestCase
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
});
$buildResp = $this->redirectRespHelper->buildRedirectResponse(
'http://domain.com/foo/bar?some=thing',
)->willReturn(new Response\RedirectResponse(''));
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)
->withAttribute(
@@ -115,42 +119,7 @@ class RedirectActionTest extends TestCase
);
$this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$buildResp->shouldHaveBeenCalled();
$track->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideRedirectConfigs
*/
public function expectedStatusCodeAndCacheIsReturnedBasedOnConfig(
int $configuredStatus,
int $configuredLifetime,
int $expectedStatus,
?string $expectedCacheControl
): void {
$this->shortenerOpts->redirectStatusCode = $configuredStatus;
$this->shortenerOpts->redirectCacheLifetime = $configuredLifetime;
$shortUrl = new ShortUrl('http://domain.com/foo/bar');
$shortCode = $shortUrl->getShortCode();
$this->urlResolver->resolveEnabledShortUrl(Argument::cetera())->willReturn($shortUrl);
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
self::assertInstanceOf(Response\RedirectResponse::class, $response);
self::assertEquals($expectedStatus, $response->getStatusCode());
self::assertEquals($response->hasHeader('Cache-Control'), $expectedCacheControl !== null);
self::assertEquals($response->getHeaderLine('Cache-Control'), $expectedCacheControl ?? '');
}
public function provideRedirectConfigs(): iterable
{
yield 'status 302' => [302, 20, 302, null];
yield 'status over 302' => [400, 20, 302, null];
yield 'status below 301' => [201, 20, 302, null];
yield 'status 301 with valid expiration' => [301, 20, 301, 'private,max-age=20'];
yield 'status 301 with zero expiration' => [301, 0, 301, 'private,max-age=30'];
yield 'status 301 with negative expiration' => [301, -20, 301, 'private,max-age=30'];
}
}

View File

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

View File

@@ -10,13 +10,16 @@ use Laminas\Diactoros\Uri;
use Mezzio\Router\Route;
use Mezzio\Router\RouteResult;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class NotFoundRedirectHandlerTest extends TestCase
{
@@ -24,11 +27,13 @@ class NotFoundRedirectHandlerTest extends TestCase
private NotFoundRedirectHandler $middleware;
private NotFoundRedirectOptions $redirectOptions;
private ObjectProphecy $helper;
public function setUp(): void
{
$this->redirectOptions = new NotFoundRedirectOptions();
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, '');
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal(), '');
}
/**
@@ -43,13 +48,16 @@ class NotFoundRedirectHandlerTest extends TestCase
$this->redirectOptions->regular404 = 'regular404';
$this->redirectOptions->baseUrl = 'baseUrl';
$expectedResp = new Response();
$buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp);
$next = $this->prophesize(RequestHandlerInterface::class);
$handle = $next->handle($request)->willReturn(new Response());
$resp = $this->middleware->process($request, $next->reveal());
self::assertInstanceOf(Response\RedirectResponse::class, $resp);
self::assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location'));
self::assertSame($expectedResp, $resp);
$buildResp->shouldHaveBeenCalledOnce();
$handle->shouldNotHaveBeenCalled();
}
@@ -91,12 +99,15 @@ class NotFoundRedirectHandlerTest extends TestCase
$req = ServerRequestFactory::fromGlobals();
$resp = new Response();
$buildResp = $this->helper->buildRedirectResponse(Argument::cetera());
$next = $this->prophesize(RequestHandlerInterface::class);
$handle = $next->handle($req)->willReturn($resp);
$result = $this->middleware->process($req, $next->reveal());
self::assertSame($resp, $result);
$buildResp->shouldNotHaveBeenCalled();
$handle->shouldHaveBeenCalledOnce();
}
}

View File

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

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Util;
use Laminas\Diactoros\Response\RedirectResponse;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelper;
class RedirectResponseHelperTest extends TestCase
{
private RedirectResponseHelper $helper;
private UrlShortenerOptions $shortenerOpts;
protected function setUp(): void
{
$this->shortenerOpts = new UrlShortenerOptions();
$this->helper = new RedirectResponseHelper($this->shortenerOpts);
}
/**
* @test
* @dataProvider provideRedirectConfigs
*/
public function expectedStatusCodeAndCacheIsReturnedBasedOnConfig(
int $configuredStatus,
int $configuredLifetime,
int $expectedStatus,
?string $expectedCacheControl
): void {
$this->shortenerOpts->redirectStatusCode = $configuredStatus;
$this->shortenerOpts->redirectCacheLifetime = $configuredLifetime;
$response = $this->helper->buildRedirectResponse('destination');
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertEquals($expectedStatus, $response->getStatusCode());
self::assertTrue($response->hasHeader('Location'));
self::assertEquals('destination', $response->getHeaderLine('Location'));
self::assertEquals($expectedCacheControl !== null, $response->hasHeader('Cache-Control'));
self::assertEquals($expectedCacheControl ?? '', $response->getHeaderLine('Cache-Control'));
}
public function provideRedirectConfigs(): iterable
{
yield 'status 302' => [302, 20, 302, null];
yield 'status over 302' => [400, 20, 302, null];
yield 'status below 301' => [201, 20, 302, null];
yield 'status 301 with valid expiration' => [301, 20, 301, 'private,max-age=20'];
yield 'status 301 with zero expiration' => [301, 0, 301, 'private,max-age=30'];
yield 'status 301 with negative expiration' => [301, -20, 301, 'private,max-age=30'];
}
}

View File

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