Refactored backend to use query strings instead of URL rewriting

This commit is contained in:
Chris Kankiewicz
2020-02-05 10:25:05 -07:00
parent 5f86bf79b1
commit fcdd9379c6
16 changed files with 379 additions and 317 deletions

View File

@@ -12,7 +12,7 @@ server {
location = /robots.txt { access_log off; log_not_found off; }
location / {
try_files $uri /index.php$is_args$args;
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php {

View File

@@ -7,6 +7,7 @@
RewriteRule ^ %1 [L,R=301]
# Handle the front controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
</IfModule>

View File

@@ -2,7 +2,6 @@
namespace App\Bootstrap;
use App\Middleware;
use App\Providers;
use DI\Bridge\Slim\Bridge;
use DI\Container;
@@ -22,7 +21,7 @@ class AppManager
/** @const Constant description */
protected const MIDDLEWARES = [
Middleware\StripBasePathMiddleware::class,
// ...
];
/** @var Container The applicaiton container */

View File

@@ -1,101 +0,0 @@
<?php
namespace App\Controllers;
use DI\Container;
use PHLAK\Config\Config;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class DirectoryController
{
/** @var Config App configuration component */
protected $config;
/** @var Container Application container */
protected $container;
/** @var Twig Twig templating component */
protected $view;
/**
* Create a new DirectoryController object.
*
* @param \DI\Container $container
* @param \PHLAK\Config\Config $config
* @param \Slim\Views\Twig $view
*/
public function __construct(Container $container, Config $config, Twig $view)
{
$this->container = $container;
$this->config = $config;
$this->view = $view;
}
/**
* Invoke the DirectoryController.
*
* @param \Symfony\Component\Finder\Finder $files
* @param \Slim\Psr7\Response $response
* @param string $path
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(
Finder $files,
Request $request,
Response $response,
string $path = '.'
) {
$search = $request->getQueryParams()['search'] ?? null;
try {
$files = $files->in($path);
} catch (DirectoryNotFoundException $exception) {
return $this->view->render($response->withStatus(404), '404.twig');
}
return $this->view->render($response, 'index.twig', [
'files' => $search ? $files->name(
sprintf('/(?:.*)%s(?:.*)/i', preg_quote($search, '/'))
) : $files->depth(0),
'path' => $path,
'readme' => $this->readme($path),
'search' => $search,
]);
}
/**
* Return the README file for a given path.
*
* @param string $path
*
* @return \Symfony\Component\Finder\SplFileInfo|null
*/
protected function readme($path): ?SplFileInfo
{
if (! $this->config->get('app.display_readmes', true)) {
return null;
}
$readmes = Finder::create()->in($path)->depth(0)->name('/^README(?:\..+)?$/i');
$readmes->filter(function (SplFileInfo $file) {
return (bool) preg_match('/text\/.+/', mime_content_type($file->getPathname()));
});
$readmes->sort(function (SplFileInfo $file1, SplFileInfo $file2) {
return $file1->getExtension() <=> $file2->getExtension();
});
if (! $readmes->hasResults()) {
return null;
}
$readmeArray = iterator_to_array($readmes);
return array_shift($readmeArray);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Controllers;
use App\Handlers;
use DI\Container;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
class IndexController
{
/** @var Container Application container */
protected $container;
/**
* Create a new IndexController object.
*
* @param \DI\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Invoke the IndexController.
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response)
{
switch (true) {
case array_key_exists('info', $request->getQueryParams()):
return $this->container->call(Handlers\FileInfoHandler::class, [$request, $response]);
case array_key_exists('search', $request->getQueryParams()):
return $this->container->call(Handlers\SearchHandler::class, [$request, $response]);
default:
return $this->container->call(Handlers\DirectoryHandler::class, [$request, $response]);
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Handlers;
use PHLAK\Config\Config;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class DirectoryHandler
{
/** @var Config App configuration component */
protected $config;
/** @var Finder File finder component */
protected $finder;
/** @var Twig Twig templating component */
protected $view;
/**
* Create a new IndexController object.
*
* @param \PHLAK\Config\Config $config
* @param \Symfony\Component\Finder\Finder $finder
* @param \Slim\Views\Twig $view
*/
public function __construct(Config $config, Finder $finder, Twig $view)
{
$this->config = $config;
$this->finder = $finder;
$this->view = $view;
}
/**
* Invoke the IndexController.
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response)
{
$path = $request->getQueryParams()['dir'] ?? '.';
try {
$files = $this->finder->in($path)->depth(0);
} catch (DirectoryNotFoundException $exception) {
return $this->view->render($response->withStatus(404), '404.twig');
}
return $this->view->render($response, 'index.twig', [
'files' => $files,
'path' => $path,
'readme' => $this->readme($files),
]);
}
/**
* Return the README file within a finder object.
*
* @param \Symfony\Component\Finder\Finder $files
*
* @return \Symfony\Component\Finder\SplFileInfo|null
*/
protected function readme(Finder $files): ?SplFileInfo
{
if (! $this->config->get('app.display_readmes', true)) {
return null;
}
$readmes = (clone $files)->name('/^README(?:\..+)?$/i');
$readmes->filter(function (SplFileInfo $file) {
return (bool) preg_match('/text\/.+/', mime_content_type($file->getPathname()));
})->sort(function (SplFileInfo $file1, SplFileInfo $file2) {
return $file1->getExtension() <=> $file2->getExtension();
});
if (! $readmes->hasResults()) {
return null;
}
return $readmes->getIterator()->current();
}
}

View File

@@ -1,13 +1,14 @@
<?php
namespace App\Controllers;
namespace App\Handlers;
use DI\Container;
use PHLAK\Config\Config;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use SplFileInfo;
class FileInfoController
class FileInfoHandler
{
/** @var Container The application container */
protected $container;
@@ -16,7 +17,7 @@ class FileInfoController
protected $config;
/**
* Create a new FileInfoController object.
* Create a new FileInfoHandler object.
*
* @param \DI\Container $container
* @param \PHLAK\Config\Config $config
@@ -28,13 +29,15 @@ class FileInfoController
}
/**
* Invoke the FileInfoController.
* Invoke the FileInfoHandler.
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
* @param string $path
*/
public function __invoke(Response $response, string $path = '.')
public function __invoke(Request $request, Response $response)
{
$path = $request->getQueryParams()['info'];
$file = new SplFileInfo(
realpath($this->container->get('base_path') . '/' . $path)
);

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Handlers;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
use Symfony\Component\Finder\Finder;
class SearchHandler
{
/** @var Finder File finder component */
protected $finder;
/** @var Twig Twig templating component */
protected $view;
/**
* Create a new SearchHandler object.
*
* @param \Symfony\Component\Finder\Finder $finder
* @param \Slim\Views\Twig $view
*/
public function __construct(Finder $finder, Twig $view)
{
$this->finder = $finder;
$this->view = $view;
}
/**
* Invoke the SearchHandler.
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response)
{
$search = $request->getQueryParams()['search'];
$files = $this->finder->in('.')->name(
sprintf('/(?:.*)%s(?:.*)/i', preg_quote($search, '/'))
);
return $this->view->render($response, 'index.twig', [
'files' => $files,
'search' => $search,
]);
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Middleware;
use DI\Container;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class StripBasePathMiddleware
{
/** @var Container The application container */
protected $container;
/**
* Create a new CanonicalizePathMiddleware object.
*
* @param \DI\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Canonicalize the received path variable.
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
* @param callable $next
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$path = $request->getUri()->getPath();
$request = $request->withUri(
$request->getUri()->withPath($this->stripBasePath($path))
);
return $handler->handle($request);
}
/**
* Strip the base URL path from a path string.
*
* @param string $path
*
* @return string
*/
protected function stripBasePath(string $path): string
{
$pattern = sprintf('/^%s/', preg_quote(dirname($_SERVER['SCRIPT_NAME']), '/'));
return '/' . ltrim(preg_replace($pattern, '', $path), '/');
}
}

View File

@@ -21,8 +21,7 @@ $container->set('base_path', __DIR__);
$app = $container->call(AppManager::class);
// Register routes
$app->get('/file-info/[{path:.*}]', Controllers\FileInfoController::class);
$app->get('/[{path:.*}]', Controllers\DirectoryController::class);
$app->get('/[{path:.*}]', Controllers\IndexController::class);
// Engage!
$app->run();

View File

@@ -1,42 +0,0 @@
<?php
namespace Tests\Controllers;
use App\Controllers\FileInfoController;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Response;
use Tests\TestCase;
class FileInfoControllerTest extends TestCase
{
public function test_it_can_return_a_successful_response(): void
{
$controller = new FileInfoController($this->container, $this->config);
$response = $controller(new Response(), 'README.md');
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
}
public function test_it_can_return_a_not_found_response(): void
{
$controller = new FileInfoController($this->container, $this->config);
$response = $controller(new Response(), 'not_a_file.test');
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_it_returns_an_error_when_file_size_is_too_large(): void
{
$this->config->set('app.max_hash_size', 10);
$controller = new FileInfoController($this->container, $this->config);
$response = $controller(new Response(), 'README.md');
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(500, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Controllers;
use App\Controllers\IndexController;
use App\Handlers;
use DI\Container;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Tests\TestCase;
class IndexControllerTest extends TestCase
{
public function test_it_handles_a_file_info_request(): void
{
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'file.test']);
$container = $this->createMock(Container::class);
$container->expects($this->once())->method('call')->with(
Handlers\FileInfoHandler::class,
[$request, $response = new Response]
);
$controller = new IndexController($container);
$controller($request, $response);
}
public function test_it_handles_a_search_request(): void
{
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['search' => 'file.test']);
$container = $this->createMock(Container::class);
$container->expects($this->once())->method('call')->with(
Handlers\SearchHandler::class,
[$request, $response = new Response]
);
$controller = new IndexController($container);
$controller($request, $response);
}
public function test_it_handles_a_directory_request(): void
{
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['dir' => 'some/directory']);
$container = $this->createMock(Container::class);
$container->expects($this->once())->method('call')->with(
Handlers\DirectoryHandler::class,
[$request, $response = new Response]
);
$controller = new IndexController($container);
$controller($request, $response);
}
public function test_it_handles_a_directory_request_by_default(): void
{
$container = $this->createMock(Container::class);
$container->expects($this->once())->method('call')->with(
Handlers\DirectoryHandler::class,
[$request = $this->createMock(Request::class), $response = new Response]
);
$controller = new IndexController($container);
$controller($request, $response);
}
}

View File

@@ -2,7 +2,7 @@
namespace Tests\Controllers;
use App\Controllers\DirectoryController;
use App\Handlers\DirectoryHandler;
use App\Providers\TwigProvider;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
@@ -11,7 +11,7 @@ use Slim\Views\Twig;
use Symfony\Component\Finder\Finder;
use Tests\TestCase;
class DirectoryControllerTest extends TestCase
class DirectoryHandlerTest extends TestCase
{
/** @dataProvider configOptions */
public function test_it_returns_a_successful_response(
@@ -25,18 +25,13 @@ class DirectoryControllerTest extends TestCase
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$controller = new DirectoryHandler(
$this->config,
new Finder,
$this->container->get(Twig::class)
);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$this->createMock(Request::class),
new Response()
);
$response = $controller($this->createMock(Request::class), new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
@@ -54,19 +49,17 @@ class DirectoryControllerTest extends TestCase
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$controller = new DirectoryHandler(
$this->config,
new Finder,
$this->container->get(Twig::class)
);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['dir' => 'subdir']);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$this->createMock(Request::class),
new Response(),
'subdir'
);
$response = $controller($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
@@ -76,48 +69,19 @@ class DirectoryControllerTest extends TestCase
{
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$this->config,
$this->container->get(Twig::class)
);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$this->createMock(Request::class),
new Response(),
'404'
);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_it_returns_a_successful_response_for_a_search_request(): void
{
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$controller = new DirectoryHandler(
$this->config,
new Finder,
$this->container->get(Twig::class)
);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn([
'search' => 'charlie'
]);
$request->method('getQueryParams')->willReturn(['dir' => '404']);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$request,
new Response()
);
$response = $controller($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(404, $response->getStatusCode());
}
/**

View File

@@ -0,0 +1,59 @@
<?php
namespace Tests\Handlers;
use App\Handlers\FileInfoHandler;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Tests\TestCase;
class FileInfoHandlerTest extends TestCase
{
public function test_it_can_return_a_successful_response(): void
{
$handler = new FileInfoHandler($this->container, $this->config);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'README.md']);
$response = $handler($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(json_encode([
'hashes' => [
'md5' => '6e35c5c3bca40dfb96cbb449fd06df38',
'sha1' => '7ea619032a992824fac30026d3df919939c7ebfb',
'sha256' => '40adf7348820699ed3e72dc950ccd8d8d538065a91eba3c76263c44b1d12df9c',
]
]), (string) $response->getBody());
}
public function test_it_can_return_a_not_found_response(): void
{
$handler = new FileInfoHandler($this->container, $this->config);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'not_a_file.test']);
$response = $handler($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_it_returns_an_error_when_file_size_is_too_large(): void
{
$this->config->set('app.max_hash_size', 10);
$handler = new FileInfoHandler($this->container, $this->config);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'README.md']);
$response = $handler($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(500, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Tests\Handlers;
use App\Handlers\SearchHandler;
use App\Providers\TwigProvider;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
use Symfony\Component\Finder\Finder;
use Tests\TestCase;
class SearchHandlerTest extends TestCase
{
public function test_it_returns_a_successful_response_for_a_search_request(): void
{
$this->container->call(TwigProvider::class);
$handler = new SearchHandler(new Finder, $this->container->get(Twig::class));
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['search' => 'charlie']);
$response = $handler($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace Tests\Middleware;
use App\Middleware\StripBasePathMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;
use Slim\Psr7\Headers;
use Slim\Psr7\Request;
use Slim\Psr7\Stream;
use Slim\Psr7\Uri;
use Tests\TestCase;
class StripBasePathMiddlewareTest extends TestCase
{
public function test_the_path_is_unchanged_for_a_request_in_the_webroot(): void
{
$middleware = new StripBasePathMiddleware($this->container);
$uri = new Uri('http', 'localhost', null, '/foo/bar');
$request = new Request('GET', $uri, new Headers, [], [], new Stream(fopen('php://memory', 'w+')));
$handler = $this->createMock(App::class);
$handler->expects($this->once())->method('handle')->with(
$this->callback(function (ServerRequestInterface $request): bool {
return $request->getUri()->getPath() == '/foo/bar';
})
);
$middleware($request, $handler);
}
public function test_it_strips_the_base_path_for_a_request_in_a_subdirectory(): void
{
$_SERVER['SCRIPT_NAME'] = '/some/dir/index.php';
$middleware = new StripBasePathMiddleware($this->container);
$uri = new Uri('http', 'localhost', null, '/some/dir/foo/bar');
$request = new Request('GET', $uri, new Headers, [], [], new Stream(fopen('php://memory', 'w+')));
$handler = $this->createMock(App::class);
$handler->expects($this->once())->method('handle')->with(
$this->callback(function (ServerRequestInterface $request): bool {
return $request->getUri()->getPath() == '/foo/bar';
})
);
$middleware($request, $handler);
}
}