diff --git a/examples/index.php b/examples/index.php
index 08156e4..dc07c9e 100644
--- a/examples/index.php
+++ b/examples/index.php
@@ -99,12 +99,11 @@
);
});
-$app->redirect('/test', '/');
-
//$app->cgi('/adminer.php', __DIR__ . '/adminer.php');
-$app->fs('/source/', __DIR__);
-//$app->redirect('/source', '/source/');
+$app->get('/LICENSE', new Frugal\FilesystemHandler(dirname(__DIR__) . '/LICENSE'));
+$app->get('/source/{path:.*}', new Frugal\FilesystemHandler(dirname(__DIR__)));
+$app->redirect('/source', '/source/');
$app->run();
$loop->run();
diff --git a/src/App.php b/src/App.php
index a4382a1..3ff3b23 100644
--- a/src/App.php
+++ b/src/App.php
@@ -86,17 +86,6 @@ public function redirect($route, $target, $code = 302)
});
}
- public function fs(string $route, string $path)
- {
- $this->get(
- rtrim($route, '/') . '[/{path:.*}]',
- new RemoveLeadingPath(
- $route,
- new FsHandler($path)
- )
- );
- }
-
public function cgi(string $route, string $path)
{
if (\php_sapi_name() === 'cli') {
diff --git a/src/FilesystemHandler.php b/src/FilesystemHandler.php
new file mode 100644
index 0000000..ac9f8b5
--- /dev/null
+++ b/src/FilesystemHandler.php
@@ -0,0 +1,113 @@
+root = $root;
+ }
+
+ public function __invoke(ServerRequestInterface $request)
+ {
+ $local = $request->getAttribute('path', '');
+ $path = \rtrim($this->root . '/' . $local, '/');
+
+ // local path should not contain "./", "../", "//" or null bytes or start with slash
+ $valid = !\preg_match('#(?:^|/)..?(?:$|/)|^/|//|\x00#', $local);
+
+ \clearstatcache();
+ if ($valid && \is_dir($path)) {
+ if ($local !== '' && \substr($local, -1) !== '/') {
+ return new Response(
+ 302,
+ [
+ 'Location' => \basename($path) . '/'
+ ]
+ );
+ }
+
+ $response = '' . $this->escapeHtml($local === '' ? '/' : $local) . '' . "\n
\n";
+
+ if ($local !== '') {
+ $response .= ' - ../
' . "\n";
+ }
+
+ $files = \scandir($path);
+ foreach ($files as $file) {
+ if ($file === '.' || $file === '..') {
+ continue;
+ }
+
+ $dir = \is_dir($path . '/' . $file) ? '/' : '';
+ $response .= ' - ' . $this->escapeHtml($file) . $dir . '
' . "\n";
+ }
+ $response .= '
' . "\n";
+
+ return new Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $response
+ );
+ } elseif ($valid && \is_file($path)) {
+ if ($local !== '' && \substr($local, -1) === '/') {
+ return new Response(
+ 302,
+ [
+ 'Location' => '../' . \basename($path)
+ ]
+ );
+ }
+
+ // Assign default MIME type here (same as nginx/Apache).
+ // Should use mime database in the future with fallback to given default.
+ // Browers are pretty good at figuring out the correct type if no charset attribute is given.
+ $headers = [
+ 'Content-Type' => 'text/plain'
+ ];
+
+ $stat = @\stat($path);
+ if ($stat !== false) {
+ $headers['Last-Modified'] = \gmdate('D, d M Y H:i:s', $stat['mtime']) . ' GMT';
+
+ if ($request->getHeaderLine('If-Modified-Since') === $headers['Last-Modified']) {
+ return new Response(304);
+ }
+ }
+
+ return new Response(
+ 200,
+ $headers,
+ \file_get_contents($path)
+ );
+ } else {
+ return new Response(
+ 404,
+ [
+ 'Content-Type' => 'text/plain; charset=utf-8'
+ ],
+ "Error 404: Not Found\n"
+ );
+ }
+ }
+
+ private function escapeHtml(string $s): string
+ {
+ return \addcslashes(
+ \str_replace(
+ ' ',
+ ' ',
+ \htmlspecialchars($s, \ENT_NOQUOTES | \ENT_SUBSTITUTE | \ENT_DISALLOWED, 'utf-8')
+ ),
+ "\0..\032\\"
+ );
+ }
+}
diff --git a/src/FsHandler.php b/src/FsHandler.php
deleted file mode 100644
index 7ec99cc..0000000
--- a/src/FsHandler.php
+++ /dev/null
@@ -1,84 +0,0 @@
-root = \rtrim($root, '/');
- }
-
- public function __invoke(ServerRequestInterface $request)
- {
- $path = $this->root . $request->getUri()->getPath();
-
- \clearstatcache();
- if (\is_dir($path)) {
- if (\substr($path, -1) !== '/') {
- return new Response(
- 302,
- [
- 'Location' => basename($path) . '/'
- ]
- );
- }
-
- $response = '' . $this->escape($path) . '' . "\n\n";
-
- $files = \scandir($path);
- foreach ($files as $file) {
- if (\is_dir($path . '/' . $file)) {
- $file .= '/';
- }
- $response .= ' - ' . $this->escape($file) . '
' . "\n";
- }
- $response .= '
' . "\n";
-
- return new Response(
- 200,
- [
- 'Content-Type' => 'text/html; charset=utf-8'
- ],
- $response
- );
- } elseif (\is_file($path)) {
- if (false && $this->xAccelSupported) {
- return new Response(
- 200,
- [
- 'X-Accel-Redirect' => $path
- ]
- );
- }
-
-
-// $header = [];
-// if (substr($path, -4) === '.txt') {
-// $header = ['Content-Type' => 'text/plain'];
-// } elseif (substr($path, -4) === '.php') {
- $header = ['Content-Type' => 'text/plain; charset=utf-8'];
-// }
-
- return new Response(
- 200,
- $header,
- \fopen($path, 'r')
- );
- } else {
- return new Response(
- 404,
- [],
- $path
- );
- }
- }
-
- private function escape(string $s)
- {
- return \htmlspecialchars($s, null, 'utf-8');
- }
-}
diff --git a/src/RemoveLeadingPath.php b/src/RemoveLeadingPath.php
deleted file mode 100644
index f04d597..0000000
--- a/src/RemoveLeadingPath.php
+++ /dev/null
@@ -1,28 +0,0 @@
-leading = $leading;
- $this->next = $next;
- }
-
- public function __invoke(ServerRequestInterface $request)
- {
- $uri = $request->getUri();
- if (\strpos($uri->getPath(), $this->leading) === 0) {
- $request = $request->withUri(
- $uri->withPath('/' . \ltrim(\substr($uri->getPath(), \strlen($this->leading)), '/')),
- true
- );
- }
-
- $next = $this->next;
- return $next($request);
- }
-}
diff --git a/tests/FilesystemHandlerTest.php b/tests/FilesystemHandlerTest.php
new file mode 100644
index 0000000..25ff81a
--- /dev/null
+++ b/tests/FilesystemHandlerTest.php
@@ -0,0 +1,304 @@
+withAttribute('path', 'LICENSE');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('text/plain', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals(file_get_contents(__DIR__ . '/../LICENSE'), (string) $response->getBody());
+ }
+
+ public function testInvokeWithValidPathToComposerJsonAndCachingHeaderWillReturnResponseNotModifiedWithoutContents()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/composer.json');
+ $request = $request->withAttribute('path', 'composer.json');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $response = $handler($request->withHeader('If-Modified-Since', $response->getHeaderLine('Last-Modified')));
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(304, $response->getStatusCode());
+ $this->assertFalse($response->hasHeader('Content-Type'));
+ $this->assertFalse($response->hasHeader('Last-Modified'));
+ $this->assertEquals('', (string) $response->getBody());
+ }
+
+ public function testInvokeWithInvalidPathWillReturnNotFoundResponse()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/invalid');
+ $request = $request->withAttribute('path', 'invalid');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("Error 404: Not Found\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithDoubleSlashWillReturnNotFoundResponse()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/LICENSE//');
+ $request = $request->withAttribute('path', 'LICENSE//');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("Error 404: Not Found\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithPathWithLeadingSlashWillReturnNotFoundResponse()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source//LICENSE');
+ $request = $request->withAttribute('path', '/LICENSE');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("Error 404: Not Found\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithPathWithDotSegmentWillReturnNotFoundResponse()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/./LICENSE');
+ $request = $request->withAttribute('path', './LICENSE');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("Error 404: Not Found\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithPathBelowRootWillReturnNotFoundResponse()
+ {
+ $handler = new FilesystemHandler(__DIR__);
+
+ $request = new ServerRequest('GET', '/source/../LICENSE');
+ $request = $request->withAttribute('path', '../LICENSE');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("Error 404: Not Found\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithBinaryPathWillReturnNotFoundResponse()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/invalid');
+ $request = $request->withAttribute('path', "bin\x00ary");
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("Error 404: Not Found\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithoutPathWillReturnResponseWithDirectoryListing()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringContainsString('/', (string) $response->getBody());
+ $this->assertStringContainsString('.github/', (string) $response->getBody());
+ $this->assertStringNotContainsString('../', (string) $response->getBody());
+ }
+
+ public function testInvokeWithEmptyPathWillReturnResponseWithDirectoryListing()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/');
+ $request = $request->withAttribute('path', '');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringContainsString('/', (string) $response->getBody());
+ $this->assertStringContainsString('.github/', (string) $response->getBody());
+ $this->assertStringNotContainsString('../', (string) $response->getBody());
+ }
+
+ public function testInvokeWithoutPathAndRootIsFileWillReturnResponseWithFileContents()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__) . '/LICENSE');
+
+ $request = new ServerRequest('GET', '/source/');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('text/plain', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals(file_get_contents(__DIR__ . '/../LICENSE'), (string) $response->getBody());
+ }
+
+ public function testInvokeWithValidPathToDirectoryWillReturnResponseWithDirectoryListing()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/.github/');
+ $request = $request->withAttribute('path', '.github/');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals(".github/\n\n", (string) $response->getBody());
+ }
+
+ public function testInvokeWithValidPathToDirectoryButWithoutTrailingSlashWillReturnRedirectToPathWithSlash()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/.github');
+ $request = $request->withAttribute('path', '.github');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(302, $response->getStatusCode());
+ $this->assertEquals('.github/', $response->getHeaderLine('Location'));
+ }
+
+ public function testInvokeWithValidPathToFileButWithTrailingSlashWillReturnRedirectToPathWithoutSlash()
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ $request = new ServerRequest('GET', '/source/LICENSE/');
+ $request = $request->withAttribute('path', 'LICENSE/');
+
+ $response = $handler($request);
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(302, $response->getStatusCode());
+ $this->assertEquals('../LICENSE', $response->getHeaderLine('Location'));
+ }
+
+ /**
+ * @dataProvider provideNames
+ * @param string $in
+ * @param string $expected
+ */
+ public function testEscapeHtml(string $in, string $expected)
+ {
+ $handler = new FilesystemHandler(dirname(__DIR__));
+
+ //$out = $handler->escapeHtml($in);
+ $ref = new ReflectionMethod($handler, 'escapeHtml');
+ $ref->setAccessible(true);
+ $out = $ref->invoke($handler, $in);
+
+ $this->assertEquals($expected, $out);
+ }
+
+ public function provideNames()
+ {
+ return [
+ [
+ 'hello/',
+ 'hello/'
+ ],
+ [
+ 'hellö.txt',
+ 'hellö.txt'
+ ],
+ [
+ 'hello world',
+ 'hello world'
+ ],
+ [
+ 'hello world',
+ 'hello world'
+ ],
+ [
+ ' hello world ',
+ ' hello world '
+ ],
+ [
+ "hello\nworld",
+ 'hello\nworld'
+ ],
+ [
+ "hello\tworld",
+ 'hello\tworld'
+ ],
+ [
+ "hello\\nworld",
+ 'hello\\\\nworld'
+ ],
+ [
+ 'hllo',
+ 'h<e>llo'
+ ],
+ [
+ utf8_decode('hellö.txt'),
+ 'hell�.txt'
+ ],
+ [
+ "bin\00ary",
+ 'bin�ary'
+ ]
+ ];
+ }
+}
diff --git a/tests/acceptance.sh b/tests/acceptance.sh
index fde8446..533e17e 100755
--- a/tests/acceptance.sh
+++ b/tests/acceptance.sh
@@ -14,7 +14,6 @@ skipif() {
}
out=$(curl -v $base/ 2>&1); match "HTTP/.* 200" && match -iv "Content-Type:"
-out=$(curl -v $base/test 2>&1); match -i "Location: /" && match -iP "Content-Type: text/html[\r\n]"
out=$(curl -v $base/invalid 2>&1); match "HTTP/.* 404" && match -iP "Content-Type: text/html[\r\n]"
out=$(curl -v $base// 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/ 2>&1 -X POST); match "HTTP/.* 405"
@@ -69,4 +68,15 @@ out=$(curl -v $base/users 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/users/ 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/users/a/b 2>&1); match "HTTP/.* 404"
+out=$(curl -v $base/LICENSE 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: text/plain[\r\n]"
+out=$(curl -v $base/source 2>&1); match -i "Location: /source/" && match -iP "Content-Type: text/html[\r\n]"
+out=$(curl -v $base/source/ 2>&1); match "HTTP/.* 200"
+out=$(curl -v $base/source/LICENSE 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: text/plain[\r\n]"
+out=$(curl -v $base/source/LICENSE/ 2>&1); match -i "Location: ../LICENSE"
+out=$(curl -v $base/source/LICENSE// 2>&1); match "HTTP/.* 404"
+out=$(curl -v $base/source//LICENSE 2>&1); match "HTTP/.* 404"
+out=$(curl -v $base/source/tests 2>&1); match -i "Location: tests/"
+out=$(curl -v $base/source/invalid 2>&1); match "HTTP/.* 404"
+out=$(curl -v $base/source/bin%00ary 2>&1); match "HTTP/.* 40[40]" # expects 404, but not processed with nginx (400) and Apache (404)
+
echo "OK ($n)"