Added alternate base path configuration

This commit is contained in:
Chris Kankiewicz
2025-03-14 14:53:15 -07:00
parent c587d1f832
commit 871a43a1ce
22 changed files with 177 additions and 108 deletions

View File

@@ -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

View File

@@ -1,15 +1,19 @@
<?php
use App\Config;
use App\Factories;
use App\Middlewares;
use App\SortMethods;
use App\ViewFunctions;
use function DI\create;
use function DI\env;
use function DI\factory;
use function DI\get;
use function DI\string;
use function DI\value;
return [
/** Path definitions */
/** Path definitions and helpers */
'base_path' => 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'],

View File

@@ -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,
]);
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Controllers;
use App\Config;
use DI\Container;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use SplFileInfo;
use Symfony\Contracts\Translation\TranslatorInterface;
class FileController
{
public function __construct(
private Container $container,
private Config $config,
private TranslatorInterface $translator
) {}
public function __invoke(Request $request, Response $response): ResponseInterface
{
$path = $this->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())
);
}
}

View File

@@ -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'));

View File

@@ -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]);
}
}

View File

@@ -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. */

View File

@@ -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(',')));
}

View File

@@ -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 {

View File

@@ -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 '';
}

View File

@@ -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. */

View File

@@ -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=.';

View File

@@ -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": "*",

View File

@@ -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',

View File

@@ -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),

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Controllers;
use App\Controllers\FileController;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Tests\TestCase;
/** @covers \App\Controllers\FileController */
class FileControllerTest extends TestCase
{
public function test_it_returns_a_successful_response_for_a_file_request(): void
{
$controller = $this->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());
}
}

View File

@@ -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']);

View File

@@ -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' => '']);

View File

@@ -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']);

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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'));