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"; + + 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"; - - 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)"