diff --git a/README.md b/README.md index 4f5c333..cfad695 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Features Requirements ------------ - - Directory Lister requires [PHP](https://www.php.net/) >= 8.0 + - Directory Lister requires [PHP](https://www.php.net/) >= 8.1 - The [Zip](https://www.php.net/manual/en/book.zip.php) extension is required for zip downloads - The [DOM](https://www.php.net/en/dom) and [Fileinfo](https://www.php.net/manual/en/book.fileinfo.php) extensions are required for README rendering diff --git a/app/config/container.php b/app/config/container.php index 6644ab8..1d01ba8 100644 --- a/app/config/container.php +++ b/app/config/container.php @@ -1,15 +1,19 @@ dirname(__DIR__, 2), 'app_path' => dirname(__DIR__), 'asset_path' => string('{app_path}/assets'), @@ -18,6 +22,8 @@ return [ 'source_path' => string('{app_path}/src'), 'translations_path' => string('{app_path}/translations'), 'views_path' => string('{app_path}/views'), + 'files_path' => env('FILES_PATH', get('base_path')), + 'full_path' => value(fn (string $path, Config $config): string => $config->get('files_path') . '/' . $path), /** Array of application files (to be hidden) */ 'app_files' => ['app', 'index.php', '.analytics', '.env', '.env.example', '.hidden'], diff --git a/app/src/Controllers/DirectoryController.php b/app/src/Controllers/DirectoryController.php index 079945b..319829e 100644 --- a/app/src/Controllers/DirectoryController.php +++ b/app/src/Controllers/DirectoryController.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Config; +use DI\Container; use Exception; use Psr\Http\Message\ResponseInterface; use Slim\Psr7\Request; @@ -16,6 +17,7 @@ class DirectoryController { /** Create a new IndexController object. */ public function __construct( + private Container $container, private Config $config, private Finder $finder, private Twig $view, @@ -25,10 +27,11 @@ class DirectoryController /** Invoke the IndexController. */ public function __invoke(Request $request, Response $response): ResponseInterface { - $path = $request->getQueryParams()['dir'] ?? '.'; + $relativePath = $request->getQueryParams()['dir'] ?? '.'; + $fullPath = $this->container->call('full_path', ['path' => $relativePath]); try { - $files = $this->finder->in($path)->depth(0); + $files = $this->finder->in($fullPath)->depth(0); } catch (Exception $exception) { return $this->view->render($response->withStatus(404), 'error.twig', [ 'message' => $this->translator->trans('error.directory_not_found'), @@ -37,9 +40,9 @@ class DirectoryController return $this->view->render($response, 'index.twig', [ 'files' => $files, - 'path' => $path, + 'path' => $relativePath, 'readme' => $this->readme($files), - 'title' => $path == '.' ? 'Home' : $path, + 'title' => $relativePath == '.' ? 'Home' : $relativePath, ]); } diff --git a/app/src/Controllers/FileController.php b/app/src/Controllers/FileController.php new file mode 100644 index 0000000..cb6d7a4 --- /dev/null +++ b/app/src/Controllers/FileController.php @@ -0,0 +1,41 @@ +container->call('full_path', ['path' => $request->getQueryParams()['file']]); + + $file = new SplFileInfo((string) realpath($path)); + + if (! $file->isFile()) { + return $response->withStatus(404, $this->translator->trans('error.file_not_found')); + } + + $response = $response->withHeader('Content-Description', 'File Transfer'); + $response = $response->withHeader('Content-Disposition', sprintf('attachment; filename="%s"', $file->getFilename())); + $response = $response->withHeader('Content-Length', $file->getSize()); + $response = $response->withHeader('Content-Type', $file->getType()); + + return $response->withBody( + (new StreamFactory)->createStreamFromFile($file->getRealPath()) + ); + } +} diff --git a/app/src/Controllers/FileInfoController.php b/app/src/Controllers/FileInfoController.php index 871c802..b4dedeb 100644 --- a/app/src/Controllers/FileInfoController.php +++ b/app/src/Controllers/FileInfoController.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Config; +use DI\Container; use Psr\Http\Message\ResponseInterface; use Slim\Psr7\Request; use Slim\Psr7\Response; @@ -14,6 +15,7 @@ class FileInfoController { /** Create a new FileInfoHandler object. */ public function __construct( + private Container $container, private Config $config, private CacheInterface $cache, private TranslatorInterface $translator @@ -22,11 +24,9 @@ class FileInfoController /** Invoke the FileInfoHandler. */ public function __invoke(Request $request, Response $response): ResponseInterface { - $path = $request->getQueryParams()['info']; + $path = $this->container->call('full_path', ['path' => $request->getQueryParams()['info']]); - $file = new SplFileInfo( - (string) realpath($this->config->get('base_path') . '/' . $path) - ); + $file = new SplFileInfo((string) realpath($path)); if (! $file->isFile()) { return $response->withStatus(404, $this->translator->trans('error.file_not_found')); diff --git a/app/src/Controllers/IndexController.php b/app/src/Controllers/IndexController.php index 86c39e4..36318b5 100644 --- a/app/src/Controllers/IndexController.php +++ b/app/src/Controllers/IndexController.php @@ -17,18 +17,16 @@ class IndexController /** Invoke the IndexController. */ public function __invoke(Request $request, Response $response): ResponseInterface { - switch (true) { - case array_key_exists('info', $request->getQueryParams()): - return $this->container->call(FileInfoController::class, [$request, $response]); + $firstQueryParam = array_key_first($request->getQueryParams()); - case array_key_exists('search', $request->getQueryParams()): - return $this->container->call(SearchController::class, [$request, $response]); + $controller = match ($firstQueryParam) { + 'file' => FileController::class, + 'info' => FileInfoController::class, + 'search' => SearchController::class, + 'zip' => ZipController::class, + default => DirectoryController::class, + }; - case array_key_exists('zip', $request->getQueryParams()): - return $this->container->call(ZipController::class, [$request, $response]); - - default: - return $this->container->call(DirectoryController::class, [$request, $response]); - } + return $this->container->call($controller, [$request, $response]); } } diff --git a/app/src/Controllers/ZipController.php b/app/src/Controllers/ZipController.php index f33cd2c..3787e00 100644 --- a/app/src/Controllers/ZipController.php +++ b/app/src/Controllers/ZipController.php @@ -6,6 +6,7 @@ use App\CallbackStream; use App\Config; use App\Support\Str; use DateTime; +use DI\Container; use Exception; use Psr\Http\Message\ResponseInterface; use RuntimeException; @@ -22,6 +23,7 @@ class ZipController { /** Create a new ZipHandler object. */ public function __construct( + private Container $container, private Config $config, private Finder $finder, private TranslatorInterface $translator @@ -34,7 +36,7 @@ class ZipController */ public function __invoke(Request $request, Response $response): ResponseInterface { - $path = $request->getQueryParams()['zip']; + $path = $this->container->call('full_path', ['path' => $request->getQueryParams()['zip']]); if (! $this->config->get('zip_downloads') || ! is_dir($path)) { return $response->withStatus(404, $this->translator->trans('error.file_not_found')); @@ -100,9 +102,7 @@ class ZipController protected function augmentHeadersWithEstimatedSize(Response $response, int $size): Response { - $response = $response->withHeader('Content-Length', (string) $size); - - return $response; + return $response->withHeader('Content-Length', (string) $size); } /** Return the path to a file with the preceding root path stripped. */ diff --git a/app/src/Factories/FinderFactory.php b/app/src/Factories/FinderFactory.php index 6c4f5ac..0f5efeb 100644 --- a/app/src/Factories/FinderFactory.php +++ b/app/src/Factories/FinderFactory.php @@ -60,7 +60,7 @@ class FinderFactory { if (! $this->pattern instanceof Pattern) { $this->pattern = Pattern::make(sprintf('%s{%s}', Pattern::escape( - $this->config->get('base_path') . DIRECTORY_SEPARATOR + $this->config->get('files_path') . DIRECTORY_SEPARATOR ), $this->hiddenFiles->implode(','))); } diff --git a/app/src/ViewFunctions/Breadcrumbs.php b/app/src/ViewFunctions/Breadcrumbs.php index 70e25a2..9f0c904 100644 --- a/app/src/ViewFunctions/Breadcrumbs.php +++ b/app/src/ViewFunctions/Breadcrumbs.php @@ -24,7 +24,7 @@ class Breadcrumbs extends ViewFunction public function __invoke(string $path): Collection { return Str::explode($path, $this->directorySeparator)->diffAssoc( - explode($this->directorySeparator, $this->config->get('base_path')) + explode($this->directorySeparator, $this->config->get('files_path')) )->filter(static function (string $crumb): bool { return ! in_array($crumb, [null, '.']); })->reduce(function (Collection $carry, string $crumb): Collection { diff --git a/app/src/ViewFunctions/FileUrl.php b/app/src/ViewFunctions/FileUrl.php index 1569de6..1f177ec 100644 --- a/app/src/ViewFunctions/FileUrl.php +++ b/app/src/ViewFunctions/FileUrl.php @@ -9,12 +9,12 @@ class FileUrl extends Url /** Return the URL for a given path and action. */ public function __invoke(string $path = '/'): string { - $path = $this->stripLeadingSlashes($path); - if (is_file($path)) { - return $this->escape($path); + return sprintf('?file=%s', $this->escape($this->normalizePath($path))); } + $path = $this->normalizePath($path); + if ($path === '') { return ''; } diff --git a/app/src/ViewFunctions/Url.php b/app/src/ViewFunctions/Url.php index 8f2da6d..fe5b089 100644 --- a/app/src/ViewFunctions/Url.php +++ b/app/src/ViewFunctions/Url.php @@ -2,6 +2,7 @@ namespace App\ViewFunctions; +use App\Config; use App\Support\Str; class Url extends ViewFunction @@ -10,13 +11,34 @@ class Url extends ViewFunction /** Create a new Url object. */ public function __construct( + private Config $config, private string $directorySeparator = DIRECTORY_SEPARATOR ) {} /** Return the URL for a given path. */ public function __invoke(string $path = '/'): string { - return $this->escape($this->stripLeadingSlashes($path)); + return $this->escape($this->normalizePath($path)); + } + + /** Strip base path and leading slashes (and a single dot) from a path */ + protected function normalizePath(string $path): string + { + return $this->stripLeadingSlashes($this->stripBasePath($path)); + } + + /** Strip the base path from the beginning of a path. */ + protected function stripBasePath(string $path): string + { + $basePath = $this->config->get('files_path') . $this->directorySeparator; + + $position = strpos($path, $basePath); + + if ($position !== 0) { + return $path; + } + + return (string) substr_replace($path, '', 0, strlen($basePath)); } /** Strip all leading slashes (and a single dot) from a path. */ diff --git a/app/src/ViewFunctions/ZipUrl.php b/app/src/ViewFunctions/ZipUrl.php index a13b2a8..7fffb0f 100644 --- a/app/src/ViewFunctions/ZipUrl.php +++ b/app/src/ViewFunctions/ZipUrl.php @@ -9,7 +9,7 @@ class ZipUrl extends Url /** Return the URL for a given path and action. */ public function __invoke(string $path = '/'): string { - $path = $this->stripLeadingSlashes($path); + $path = $this->normalizePath($path); if ($path === '') { return '?zip=.'; diff --git a/composer.json b/composer.json index 7366761..a96e78c 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "issues": "https://github.com/DirectoryLister/DirectoryLister/issues" }, "require": { - "php": "^8.1 || ^8.2 || ^8.3 || ^8.4", + "php": ">= 8.1", "ext-dom": "*", "ext-fileinfo": "*", "ext-zip": "*", diff --git a/index.php b/index.php index 6323471..c7e8d27 100644 --- a/index.php +++ b/index.php @@ -6,12 +6,12 @@ use Dotenv\Dotenv; require __DIR__ . '/app/vendor/autoload.php'; -// Set file access restrictions -ini_set('open_basedir', __DIR__); - // Initialize environment variable handler Dotenv::createUnsafeImmutable(__DIR__)->safeLoad(); +// Set file access restrictions +ini_set('open_basedir', implode(PATH_SEPARATOR, [__DIR__, getenv('FILES_PATH')])); + // Initialize the container $container = BootManager::createContainer( __DIR__ . '/app/config', diff --git a/tests/Controllers/DirectoryControllerTest.php b/tests/Controllers/DirectoryControllerTest.php index 7c5297d..b98fd24 100644 --- a/tests/Controllers/DirectoryControllerTest.php +++ b/tests/Controllers/DirectoryControllerTest.php @@ -24,12 +24,7 @@ class DirectoryControllerTest extends TestCase $this->container->set('hide_vcs_files', $hideVcsFiles); $this->container->set('display_readmes', $displayReadmes); - $controller = new DirectoryController( - $this->config, - new Finder, - $this->container->get(Twig::class), - $this->container->get(TranslatorInterface::class) - ); + $controller = $this->container->get(DirectoryController::class); chdir($this->filePath('.')); $response = $controller($this->createMock(Request::class), new Response); @@ -48,12 +43,7 @@ class DirectoryControllerTest extends TestCase $this->container->set('hide_vcs_files', $hideVcsFiles); $this->container->set('display_readmes', $displayReadmes); - $controller = new DirectoryController( - $this->config, - new Finder, - $this->container->get(Twig::class), - $this->container->get(TranslatorInterface::class) - ); + $controller = $this->container->get(DirectoryController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['dir' => 'subdir']); @@ -68,6 +58,7 @@ class DirectoryControllerTest extends TestCase public function test_it_returns_a_404_error_when_not_found(): void { $controller = new DirectoryController( + $this->container, $this->config, new Finder, $this->container->get(Twig::class), diff --git a/tests/Controllers/FileControllerTest.php b/tests/Controllers/FileControllerTest.php new file mode 100644 index 0000000..74f0906 --- /dev/null +++ b/tests/Controllers/FileControllerTest.php @@ -0,0 +1,48 @@ +container->get(FileController::class); + + $request = $this->createMock(Request::class); + $request->method('getQueryParams')->willReturn(['file' => 'README.md']); + + $response = $controller($request, new Response); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + + $this->assertSame([ + 'Content-Description' => ['File Transfer'], + 'Content-Disposition' => ['attachment; filename="README.md"'], + 'Content-Length' => ['30'], + 'Content-Type' => ['file'] + ], $response->getHeaders()); + + $this->assertSame("Test README.md; please ignore\n", (string) $response->getBody()); + } + + public function test_it_returns_a_404_error_when_not_found(): void + { + $controller = $this->container->get(FileController::class); + + $request = $this->createMock(Request::class); + $request->method('getQueryParams')->willReturn(['file' => '404']); + + $response = $controller($request, new Response); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(404, $response->getStatusCode()); + } +} diff --git a/tests/Controllers/FileInfoControllerTest.php b/tests/Controllers/FileInfoControllerTest.php index 12ad09d..8688d03 100644 --- a/tests/Controllers/FileInfoControllerTest.php +++ b/tests/Controllers/FileInfoControllerTest.php @@ -6,7 +6,6 @@ use App\Controllers\FileInfoController; use Psr\Http\Message\ResponseInterface; use Slim\Psr7\Request; use Slim\Psr7\Response; -use Symfony\Contracts\Translation\TranslatorInterface; use Tests\TestCase; /** @covers \App\Controllers\FileInfoController */ @@ -14,11 +13,7 @@ class FileInfoControllerTest extends TestCase { public function test_it_can_return_a_successful_response(): void { - $handler = new FileInfoController( - $this->config, - $this->cache, - $this->container->get(TranslatorInterface::class) - ); + $handler = $this->container->get(FileInfoController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['info' => 'README.md']); @@ -38,11 +33,7 @@ class FileInfoControllerTest extends TestCase public function test_it_can_return_a_not_found_response(): void { - $handler = new FileInfoController( - $this->config, - $this->cache, - $this->container->get(TranslatorInterface::class) - ); + $handler = $this->container->get(FileInfoController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['info' => 'not_a_file.test']); @@ -56,11 +47,8 @@ class FileInfoControllerTest extends TestCase public function test_it_returns_an_error_when_file_size_is_too_large(): void { $this->container->set('max_hash_size', 10); - $handler = new FileInfoController( - $this->config, - $this->cache, - $this->container->get(TranslatorInterface::class) - ); + + $handler = $this->container->get(FileInfoController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['info' => 'README.md']); diff --git a/tests/Controllers/SearchControllerTest.php b/tests/Controllers/SearchControllerTest.php index bad95c4..f88121b 100644 --- a/tests/Controllers/SearchControllerTest.php +++ b/tests/Controllers/SearchControllerTest.php @@ -6,9 +6,6 @@ use App\Controllers\SearchController; use Psr\Http\Message\ResponseInterface; use Slim\Psr7\Request; use Slim\Psr7\Response; -use Slim\Views\Twig; -use Symfony\Component\Finder\Finder; -use Symfony\Contracts\Translation\TranslatorInterface; use Tests\TestCase; /** @covers \App\Controllers\SearchController */ @@ -16,11 +13,7 @@ class SearchControllerTest extends TestCase { public function test_it_returns_a_successful_response_for_a_search_request(): void { - $handler = new SearchController( - new Finder, - $this->container->get(Twig::class), - $this->container->get(TranslatorInterface::class) - ); + $handler = $this->container->get(SearchController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['search' => 'charlie']); @@ -35,11 +28,7 @@ class SearchControllerTest extends TestCase public function test_it_returns_no_results_found_when_there_are_no_results(): void { - $handler = new SearchController( - new Finder, - $this->container->get(Twig::class), - $this->container->get(TranslatorInterface::class) - ); + $handler = $this->container->get(SearchController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['search' => 'test search; please ignore']); @@ -54,11 +43,7 @@ class SearchControllerTest extends TestCase public function test_it_returns_no_results_found_for_a_blank_search(): void { - $handler = new SearchController( - new Finder, - $this->container->get(Twig::class), - $this->container->get(TranslatorInterface::class) - ); + $handler = $this->container->get(SearchController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['search' => '']); diff --git a/tests/Controllers/ZipControllerTest.php b/tests/Controllers/ZipControllerTest.php index 37aa87e..58c7dea 100644 --- a/tests/Controllers/ZipControllerTest.php +++ b/tests/Controllers/ZipControllerTest.php @@ -6,8 +6,6 @@ use App\Controllers\ZipController; use Psr\Http\Message\ResponseInterface; use Slim\Psr7\Request; use Slim\Psr7\Response; -use Symfony\Component\Finder\Finder; -use Symfony\Contracts\Translation\TranslatorInterface; use Tests\TestCase; /** @covers \App\Controllers\ZipController */ @@ -15,11 +13,7 @@ class ZipControllerTest extends TestCase { public function test_it_returns_a_successful_response_for_a_zip_request(): void { - $controller = new ZipController( - $this->config, - new Finder, - $this->container->get(TranslatorInterface::class) - ); + $controller = $this->container->get(ZipController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['zip' => 'subdir']); @@ -34,11 +28,7 @@ class ZipControllerTest extends TestCase public function test_it_returns_a_404_error_when_not_found(): void { - $controller = new ZipController( - $this->config, - new Finder, - $this->container->get(TranslatorInterface::class) - ); + $controller = $this->container->get(ZipController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['zip' => '404']); @@ -53,11 +43,8 @@ class ZipControllerTest extends TestCase public function test_it_returns_a_404_error_when_disabled_via_config(): void { $this->container->set('zip_downloads', false); - $controller = new ZipController( - $this->config, - new Finder, - $this->container->get(TranslatorInterface::class) - ); + + $controller = $this->container->get(ZipController::class); $request = $this->createMock(Request::class); $request->method('getQueryParams')->willReturn(['zip' => 'subdir']); diff --git a/tests/ViewFunctions/FileUrlTest.php b/tests/ViewFunctions/FileUrlTest.php index 66d5ef3..9056866 100644 --- a/tests/ViewFunctions/FileUrlTest.php +++ b/tests/ViewFunctions/FileUrlTest.php @@ -10,7 +10,7 @@ class FileUrlTest extends TestCase { public function test_it_can_return_a_url(): void { - $url = new FileUrl; + $url = $this->container->get(FileUrl::class); $this->assertEquals('', $url('/')); $this->assertEquals('', $url('./')); @@ -26,7 +26,7 @@ class FileUrlTest extends TestCase public function test_it_can_return_a_url_with_back_slashes(): void { - $url = new FileUrl('\\'); + $url = $this->container->make(FileUrl::class, ['directorySeparator' => '\\']); $this->assertEquals('', $url('\\')); $this->assertEquals('', $url('.\\')); @@ -40,7 +40,7 @@ class FileUrlTest extends TestCase public function test_url_segments_are_url_encoded(): void { - $url = new FileUrl; + $url = $this->container->get(FileUrl::class); $this->assertEquals('?dir=foo/bar%2Bbaz', $url('foo/bar+baz')); $this->assertEquals('?dir=foo/bar%23baz', $url('foo/bar#baz')); diff --git a/tests/ViewFunctions/UrlTest.php b/tests/ViewFunctions/UrlTest.php index baff833..7eb5bc3 100644 --- a/tests/ViewFunctions/UrlTest.php +++ b/tests/ViewFunctions/UrlTest.php @@ -10,7 +10,7 @@ class UrlTest extends TestCase { public function test_it_can_return_a_url(): void { - $url = new Url; + $url = $this->container->get(Url::class); $this->assertEquals('', $url('/')); $this->assertEquals('', $url('./')); @@ -26,7 +26,7 @@ class UrlTest extends TestCase public function test_it_can_return_a_url_with_back_slashes(): void { - $url = new Url('\\'); + $url = $this->container->make(Url::class, ['directorySeparator' => '\\']); $this->assertEquals('', $url('\\')); $this->assertEquals('', $url('.\\')); @@ -40,7 +40,7 @@ class UrlTest extends TestCase public function test_url_segments_are_url_encoded(): void { - $url = new Url; + $url = $this->container->get(Url::class); $this->assertEquals('foo/bar%2Bbaz', $url('foo/bar+baz')); $this->assertEquals('foo/bar%23baz', $url('foo/bar#baz')); diff --git a/tests/ViewFunctions/ZipUrlTest.php b/tests/ViewFunctions/ZipUrlTest.php index b96ea45..647f3ec 100644 --- a/tests/ViewFunctions/ZipUrlTest.php +++ b/tests/ViewFunctions/ZipUrlTest.php @@ -10,7 +10,7 @@ class ZipUrlTest extends TestCase { public function test_it_can_return_a_url(): void { - $url = new ZipUrl; + $url = $this->container->get(ZipUrl::class); $this->assertEquals('?zip=.', $url('/')); $this->assertEquals('?zip=.', $url('./')); @@ -26,7 +26,7 @@ class ZipUrlTest extends TestCase public function test_it_can_return_a_url_with_back_slashes(): void { - $url = new ZipUrl('\\'); + $url = $this->container->make(ZipUrl::class, ['directorySeparator' => '\\']); $this->assertEquals('?zip=.', $url('\\')); $this->assertEquals('?zip=.', $url('.\\')); @@ -38,7 +38,7 @@ class ZipUrlTest extends TestCase public function test_url_segments_are_url_encoded(): void { - $url = new ZipUrl; + $url = $this->container->get(ZipUrl::class); $this->assertEquals('?zip=foo/bar%2Bbaz', $url('foo/bar+baz')); $this->assertEquals('?zip=foo/bar%23baz', $url('foo/bar#baz'));