Created new middlewares to track not found visits

This commit is contained in:
Alejandro Celaya 2021-02-08 21:38:19 +01:00
parent 36be44e7b5
commit 15061d3e0d
10 changed files with 187 additions and 40 deletions

View File

@ -64,6 +64,8 @@ return [
],
'not-found' => [
'middleware' => [
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
Core\ErrorHandler\NotFoundRedirectHandler::class,
Core\ErrorHandler\NotFoundTemplateHandler::class,
],

View File

@ -15,6 +15,8 @@ return [
'dependencies' => [
'factories' => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTrackerMiddleware::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
@ -58,10 +60,11 @@ return [
],
ConfigAbstractFactory::class => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [
NotFoundRedirectOptions::class,
Util\RedirectResponseHelper::class,
'config.router.base_path',
],
Options\AppOptions::class => ['config.app_options'],

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler\Model;
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\Visit;
use function rtrim;
final class NotFoundType
{
private string $type;
private function __construct(string $type)
{
$this->type = $type;
}
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
{
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
if ($isBaseUrl) {
return new self(Visit::TYPE_BASE_URL);
}
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
if ($routeResult->isFailure()) {
return new self(Visit::TYPE_REGULAR_404);
}
if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
return new self(Visit::TYPE_INVALID_SHORT_URL);
}
return new self(self::class);
}
public function isBaseUrl(): bool
{
return $this->type === Visit::TYPE_BASE_URL;
}
public function isRegularNotFound(): bool
{
return $this->type === Visit::TYPE_REGULAR_404;
}
public function isInvalidShortUrl(): bool
{
return $this->type === Visit::TYPE_INVALID_SHORT_URL;
}
}

View File

@ -4,67 +4,48 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler;
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
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\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function rtrim;
class NotFoundRedirectHandler implements MiddlewareInterface
{
private Options\NotFoundRedirectOptions $redirectOptions;
private RedirectResponseHelperInterface $redirectResponseHelper;
private string $shlinkBasePath;
public function __construct(
Options\NotFoundRedirectOptions $redirectOptions,
RedirectResponseHelperInterface $redirectResponseHelper,
string $shlinkBasePath
RedirectResponseHelperInterface $redirectResponseHelper
) {
$this->redirectOptions = $redirectOptions;
$this->shlinkBasePath = $shlinkBasePath;
$this->redirectResponseHelper = $redirectResponseHelper;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
return $redirectResponse ?? $handler->handle($request);
}
private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface
{
$isBaseUrl = rtrim($uri->getPath(), '/') === $this->shlinkBasePath;
if ($isBaseUrl && $this->redirectOptions->hasBaseUrlRedirect()) {
if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) {
return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
}
if (!$isBaseUrl && $routeResult->isFailure() && $this->redirectOptions->hasRegular404Redirect()) {
if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) {
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getRegular404Redirect(),
);
}
if (
$routeResult->isSuccess() &&
$routeResult->getMatchedRouteName() === RedirectAction::class &&
$this->redirectOptions->hasInvalidShortUrlRedirect()
) {
if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) {
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getInvalidShortUrlRedirect(),
);
}
return null;
return $handler->handle($request);
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
class NotFoundTrackerMiddleware implements MiddlewareInterface
{
private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker)
{
$this->visitsTracker = $visitsTracker;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
$visitor = Visitor::fromRequest($request);
if ($notFoundType->isBaseUrl()) {
$this->visitsTracker->trackBaseUrlVisit($visitor);
}
if ($notFoundType->isRegularNotFound()) {
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
}
if ($notFoundType->isInvalidShortUrl()) {
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
}
return $handler->handle($request);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
class NotFoundTypeResolverMiddleware implements MiddlewareInterface
{
private string $shlinkBasePath;
public function __construct(string $shlinkBasePath)
{
$this->shlinkBasePath = $shlinkBasePath;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$notFoundType = NotFoundType::fromRequest($request, $this->shlinkBasePath);
return $handler->handle($request->withAttribute(NotFoundType::class, $notFoundType));
}
}

View File

@ -211,8 +211,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
public function countVisits(?ApiKey $apiKey = null): int
{
return (int) $this->matchSingleScalarResult(
Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')),
);
return (int) $this->matchSingleScalarResult(Spec::countOf(Spec::andX(
Spec::isNotNull('shortUrl'),
new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl'),
)));
}
}

View File

@ -29,11 +29,30 @@ class VisitsTracker implements VisitsTrackerInterface
public function track(ShortUrl $shortUrl, Visitor $visitor): void
{
$visit = Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr);
$visit = $this->trackVisit(Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr));
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
}
public function trackInvalidShortUrlVisit(Visitor $visitor): void
{
$this->trackVisit(Visit::forInvalidShortUrl($visitor));
}
public function trackBaseUrlVisit(Visitor $visitor): void
{
$this->trackVisit(Visit::forBasePath($visitor));
}
public function trackRegularNotFoundVisit(Visitor $visitor): void
{
$this->trackVisit(Visit::forRegularNotFound($visitor));
}
private function trackVisit(Visit $visit): Visit
{
$this->em->persist($visit);
$this->em->flush();
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
return $visit;
}
}

View File

@ -10,4 +10,10 @@ use Shlinkio\Shlink\Core\Model\Visitor;
interface VisitsTrackerInterface
{
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
public function trackInvalidShortUrlVisit(Visitor $visitor): void;
public function trackBaseUrlVisit(Visitor $visitor): void;
public function trackRegularNotFoundVisit(Visitor $visitor): void;
}

View File

@ -17,6 +17,7 @@ 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\Model\NotFoundType;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
@ -33,7 +34,7 @@ class NotFoundRedirectHandlerTest extends TestCase
{
$this->redirectOptions = new NotFoundRedirectOptions();
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal(), '');
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal());
}
/**
@ -64,19 +65,19 @@ class NotFoundRedirectHandlerTest extends TestCase
public function provideRedirects(): iterable
{
yield 'base URL with trailing slash' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('/')),
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))),
'baseUrl',
];
yield 'base URL without trailing slash' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('')),
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))),
'baseUrl',
];
yield 'regular 404' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar')),
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))),
'regular404',
];
yield 'invalid short URL' => [
ServerRequestFactory::fromGlobals()
$this->withNotFoundType(ServerRequestFactory::fromGlobals()
->withAttribute(
RouteResult::class,
RouteResult::fromRoute(
@ -88,7 +89,7 @@ class NotFoundRedirectHandlerTest extends TestCase
),
),
)
->withUri(new Uri('/abc123')),
->withUri(new Uri('/abc123'))),
'invalidShortUrl',
];
}
@ -96,7 +97,7 @@ class NotFoundRedirectHandlerTest extends TestCase
/** @test */
public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void
{
$req = ServerRequestFactory::fromGlobals();
$req = $this->withNotFoundType(ServerRequestFactory::fromGlobals());
$resp = new Response();
$buildResp = $this->helper->buildRedirectResponse(Argument::cetera());
@ -110,4 +111,10 @@ class NotFoundRedirectHandlerTest extends TestCase
$buildResp->shouldNotHaveBeenCalled();
$handle->shouldHaveBeenCalledOnce();
}
private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface
{
$type = NotFoundType::fromRequest($req, '');
return $req->withAttribute(NotFoundType::class, $type);
}
}