diff --git a/app/config/app.php b/app/config/app.php index 09cd61c..96bda0b 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -62,6 +62,15 @@ return [ */ 'readmes_first' => env('READMES_FIRST', false), + /** + * Comma separated list of file patterns to be directly linked. Directly + * linked files will not be served by Directory Lister but handled by the + * web server directly. This setting has no effect when FILES_PATH is set. + * + * Default value: null + */ + 'direct_links' => env('DIRECT_LINKS', null), + /** * Enable downloading of directories as a zip archive. * diff --git a/app/src/ViewFunctions/FileUrl.php b/app/src/ViewFunctions/FileUrl.php index 5921868..39c47c4 100644 --- a/app/src/ViewFunctions/FileUrl.php +++ b/app/src/ViewFunctions/FileUrl.php @@ -4,23 +4,51 @@ declare(strict_types=1); namespace App\ViewFunctions; +use PHLAK\Splat\Glob; +use PHLAK\Splat\Pattern; + class FileUrl extends Url { protected string $name = 'file_url'; - /** Return the URL for a given path and action. */ + /** Direct links pattern cache. */ + private ?Pattern $pattern = null; + public function __invoke(string $path = '/'): string { - if (is_file($path)) { - return sprintf('?file=%s', $this->escape($this->normalizePath($path))); - } + $normalizedPath = $this->normalizePath($path); - $path = $this->normalizePath($path); - - if ($path === '') { + if ($normalizedPath === '') { return ''; } - return sprintf('?dir=%s', $this->escape($path)); + $fullPath = $this->container->call('full_path', ['path' => $normalizedPath]); + $escapedPath = $this->escape($normalizedPath); + + if (is_file($fullPath)) { + return $this->isDirectLink($fullPath) ? $escapedPath : sprintf('?file=%s', $escapedPath); + } + + return sprintf('?dir=%s', $escapedPath); + } + + /** Determine if a file should be directly linked. */ + private function isDirectLink(string $path): bool + { + if ($this->config->get('base_path') !== $this->config->get('files_path')) { + return false; + } + + if ($this->config->get('direct_links') === null) { + return false; + } + + if (! $this->pattern instanceof Pattern) { + $this->pattern = Pattern::make(sprintf('%s{%s}', Pattern::escape( + $this->config->get('files_path') . DIRECTORY_SEPARATOR + ), $this->config->get('direct_links'))); + } + + return Glob::matchStart($this->pattern, $path); } } diff --git a/app/src/ViewFunctions/Url.php b/app/src/ViewFunctions/Url.php index ba2dad8..c8b9827 100644 --- a/app/src/ViewFunctions/Url.php +++ b/app/src/ViewFunctions/Url.php @@ -6,6 +6,7 @@ namespace App\ViewFunctions; use App\Config; use App\Support\Str; +use DI\Container; class Url extends ViewFunction { @@ -13,7 +14,8 @@ class Url extends ViewFunction /** @param non-empty-string $directorySeparator */ public function __construct( - private Config $config, + protected Container $container, + protected Config $config, private string $directorySeparator = DIRECTORY_SEPARATOR ) {} diff --git a/app/src/ViewFunctions/ZipUrl.php b/app/src/ViewFunctions/ZipUrl.php index c029c19..5822995 100644 --- a/app/src/ViewFunctions/ZipUrl.php +++ b/app/src/ViewFunctions/ZipUrl.php @@ -8,7 +8,6 @@ class ZipUrl extends Url { protected string $name = 'zip_url'; - /** Return the URL for a given path and action. */ public function __invoke(string $path = '/'): string { $path = $this->normalizePath($path); diff --git a/tests/ViewFunctions/FileUrlTest.php b/tests/ViewFunctions/FileUrlTest.php index 5b8e903..d1bc1d4 100644 --- a/tests/ViewFunctions/FileUrlTest.php +++ b/tests/ViewFunctions/FileUrlTest.php @@ -15,10 +15,15 @@ class FileUrlTest extends TestCase #[Test] public function it_can_return_a_url(): void { + $this->container->set('direct_links', '**/index.{htm,html},**/*.php'); + $url = $this->container->get(FileUrl::class); + // Root $this->assertEquals('', $url('/')); $this->assertEquals('', $url('./')); + + // Subdirectories $this->assertEquals('?dir=some/path', $url('some/path')); $this->assertEquals('?dir=some/path', $url('./some/path')); $this->assertEquals('?dir=some/path', $url('./some/path')); @@ -27,6 +32,16 @@ class FileUrlTest extends TestCase $this->assertEquals('?dir=0/path', $url('0/path')); $this->assertEquals('?dir=1/path', $url('1/path')); $this->assertEquals('?dir=0', $url('0')); + + // Files + $this->assertEquals('?file=subdir/alpha.scss', $url('subdir/alpha.scss')); + $this->assertEquals('?file=subdir/bravo.js', $url('subdir/bravo.js')); + $this->assertEquals('?file=subdir/charlie.bash', $url('subdir/charlie.bash')); + + // Direct Links + $this->assertEquals('direct_links/index.htm', $url('direct_links/index.htm')); + $this->assertEquals('direct_links/index.html', $url('direct_links/index.html')); + $this->assertEquals('direct_links/test.php', $url('direct_links/test.php')); } #[Test] @@ -44,7 +59,8 @@ class FileUrlTest extends TestCase $this->assertEquals('?dir=1\path', $url('1\path')); } - public function test_url_segments_are_url_encoded(): void + #[Test] + public function url_segments_are_url_encoded(): void { $url = $this->container->get(FileUrl::class); diff --git a/tests/_files/direct_links/index.htm b/tests/_files/direct_links/index.htm new file mode 100644 index 0000000..4cb122b --- /dev/null +++ b/tests/_files/direct_links/index.htm @@ -0,0 +1 @@ +