From ce945e132670585d96c472e334bb56470b487c49 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Sun, 1 Nov 2020 17:43:42 +0100 Subject: [PATCH] Add fileserver support --- app/Client/Factory.php | 37 ++++- app/Client/Fileserver/ConnectionHandler.php | 137 +++++++++++++++++++ app/Client/Fileserver/Fileserver.php | 31 +++++ app/Commands/ShareFilesCommand.php | 48 +++++++ app/Http/Controllers/Concerns/LoadsViews.php | 7 +- resources/views/client/fileserver.twig | 93 +++++++++++++ tests/Feature/Client/FileserverTest.php | 118 ++++++++++++++++ tests/fixtures/test.md | 1 + tests/fixtures/test.txt | 1 + 9 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 app/Client/Fileserver/ConnectionHandler.php create mode 100644 app/Client/Fileserver/Fileserver.php create mode 100644 app/Commands/ShareFilesCommand.php create mode 100644 resources/views/client/fileserver.twig create mode 100755 tests/Feature/Client/FileserverTest.php create mode 100644 tests/fixtures/test.md create mode 100644 tests/fixtures/test.txt diff --git a/app/Client/Factory.php b/app/Client/Factory.php index d391397..8908c3b 100644 --- a/app/Client/Factory.php +++ b/app/Client/Factory.php @@ -2,6 +2,7 @@ namespace App\Client; +use App\Client\Fileserver\Fileserver; use App\Client\Http\Controllers\AttachDataToLogController; use App\Client\Http\Controllers\ClearLogsController; use App\Client\Http\Controllers\CreateTunnelController; @@ -33,6 +34,9 @@ class Factory /** @var App */ protected $app; + /** @var Fileserver */ + protected $fileserver; + /** @var RouteGenerator */ protected $router; @@ -116,6 +120,15 @@ class Factory return $this; } + public function shareFolder(string $folder, string $name, $subdomain = null) + { + $host = $this->createFileServer($folder, $name); + + $this->share($host, $subdomain); + + return $this; + } + protected function addRoutes() { $this->router->get('/', DashboardController::class); @@ -134,18 +147,18 @@ class Factory } } - protected function detectNextFreeDashboardPort($port = 4040): int + protected function detectNextAvailablePort($startPort = 4040): int { - while (is_resource(@fsockopen('127.0.0.1', $port))) { - $port++; + while (is_resource(@fsockopen('127.0.0.1', $startPort))) { + $startPort++; } - return $port; + return $startPort; } public function createHttpServer() { - $dashboardPort = $this->detectNextFreeDashboardPort(); + $dashboardPort = $this->detectNextAvailablePort(); config()->set('expose.dashboard_port', $dashboardPort); @@ -156,11 +169,25 @@ class Factory return $this; } + public function createFileServer(string $folder, string $name) + { + $port = $this->detectNextAvailablePort(8090); + + $this->fileserver = new Fileserver($folder, $name, $port, '0.0.0.0', $this->loop); + + return "127.0.0.1:{$port}"; + } + public function getApp(): App { return $this->app; } + public function getFileserver(): Fileserver + { + return $this->fileserver; + } + public function run() { $this->loop->run(); diff --git a/app/Client/Fileserver/ConnectionHandler.php b/app/Client/Fileserver/ConnectionHandler.php new file mode 100644 index 0000000..e249e00 --- /dev/null +++ b/app/Client/Fileserver/ConnectionHandler.php @@ -0,0 +1,137 @@ +rootFolder = $rootFolder; + $this->name = $name; + $this->loop = $loop; + } + + public function handle(ServerRequestInterface $request) + { + $request = $this->createLaravelRequest($request); + $targetPath = realpath($this->rootFolder . DIRECTORY_SEPARATOR . $request->path()); + + if (! $this->isValidTarget($targetPath)) { + return new Response(404); + } + + if (is_dir($targetPath)) { + // Directory listing + $directoryContent = Finder::create() + ->depth(0) + ->sort(function ($a, $b) { + return strcmp(strtolower($a->getRealpath()), strtolower($b->getRealpath())); + }) + ->in($targetPath); + + if ($this->name !== '') { + $directoryContent->name($this->name); + } + + $parentPath = explode('/', $request->path()); + array_pop($parentPath); + $parentPath = implode('/', $parentPath); + + return new Response( + 200, + ['Content-Type' => 'text/html'], + $this->getView(null, 'client.fileserver', [ + 'currentPath' => $request->path(), + 'parentPath' => $parentPath, + 'directory' => $targetPath, + 'directoryContent' => $directoryContent + ]) + ); + } + + if (is_file($targetPath)) { + return new Response( + 200, + ['Content-Type' => mime_content_type($targetPath)], + new ReadableResourceStream(fopen($targetPath, 'r'), $this->loop) + ); + } + } + + protected function isValidTarget(string $targetPath): bool + { + if (! file_exists($targetPath)) { + return false; + } + + if ($this->name !== '') { + $filter = new class(basename($targetPath), [$this->name]) extends FilenameFilterIterator { + protected $filename; + + public function __construct(string $filename, array $matchPatterns) + { + $this->filename = $filename; + + foreach ($matchPatterns as $pattern) { + $this->matchRegexps[] = $this->toRegex($pattern); + } + } + + public function accept() + { + return $this->isAccepted($this->filename); + } + }; + return $filter->accept(); + } + + return true; + } + + protected function createLaravelRequest(ServerRequestInterface $request): Request + { + try { + parse_str($request->getBody(), $bodyParameters); + } catch (\Throwable $e) { + $bodyParameters = []; + } + + $serverRequest = (new ServerRequest( + $request->getMethod(), + $request->getUri(), + $request->getHeaders(), + $request->getBody(), + $request->getProtocolVersion(), + )) + ->withQueryParams(QueryParameters::create($request)->all()) + ->withParsedBody($bodyParameters); + + return Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest)); + } +} diff --git a/app/Client/Fileserver/Fileserver.php b/app/Client/Fileserver/Fileserver.php new file mode 100644 index 0000000..af5fbc9 --- /dev/null +++ b/app/Client/Fileserver/Fileserver.php @@ -0,0 +1,31 @@ +handle($request); + }); + + $this->socket = new SocketServer("{$address}:{$port}", $loop); + + $server->listen($this->socket); + } + + public function getSocket(): SocketServer + { + return $this->socket; + } +} diff --git a/app/Commands/ShareFilesCommand.php b/app/Commands/ShareFilesCommand.php new file mode 100644 index 0000000..7836454 --- /dev/null +++ b/app/Commands/ShareFilesCommand.php @@ -0,0 +1,48 @@ +bind(CliRequestLogger::class, function () { + return new CliRequestLogger(new ConsoleOutput()); + }); + + return $this; + } + + public function handle() + { + if ( !is_dir($this->argument('folder'))) { + throw new \InvalidArgumentException('The folder '.$this->argument('folder').' does not exist.'); + } + + $this->configureConnectionLogger(); + + (new Factory()) + ->setLoop(app(LoopInterface::class)) + ->setHost(config('expose.host', 'localhost')) + ->setPort(config('expose.port', 8080)) + ->setAuth($this->option('auth')) + ->createClient() + ->shareFolder( + $this->argument('folder'), + $this->option('name') ?? '', + explode(',', $this->option('subdomain')) + ) + ->createHttpServer() + ->run(); + } +} diff --git a/app/Http/Controllers/Concerns/LoadsViews.php b/app/Http/Controllers/Concerns/LoadsViews.php index fd60ca8..a8e0a76 100644 --- a/app/Http/Controllers/Concerns/LoadsViews.php +++ b/app/Http/Controllers/Concerns/LoadsViews.php @@ -9,7 +9,7 @@ use Twig\Loader\ArrayLoader; trait LoadsViews { - protected function getView(ConnectionInterface $connection, string $view, array $data = []) + protected function getView(?ConnectionInterface $connection, string $view, array $data = []) { $templatePath = implode(DIRECTORY_SEPARATOR, explode('.', $view)); @@ -23,7 +23,10 @@ trait LoadsViews $data = array_merge($data, [ 'request' => $connection->laravelRequest ?? null, ]); - +try { return stream_for($twig->render('template', $data)); +} catch (\Throwable $e) { + var_dump($e->getMessage()); +} } } diff --git a/resources/views/client/fileserver.twig b/resources/views/client/fileserver.twig new file mode 100644 index 0000000..1592317 --- /dev/null +++ b/resources/views/client/fileserver.twig @@ -0,0 +1,93 @@ + + + Expose Dashboard :: {{ subdomains|join(", ") }} + + + + + + + +
+
+
+
+

+ {{ directory }} +

+
+
+
+ {% macro bytesToSize(bytes) %} + {% set kilobyte = 1024 %} + {% set megabyte = kilobyte * 1024 %} + {% set gigabyte = megabyte * 1024 %} + {% set terabyte = gigabyte * 1024 %} + + {% if bytes < kilobyte %} + {{ bytes ~ ' B' }} + {% elseif bytes < megabyte %} + {{ (bytes / kilobyte)|number_format(2, '.') ~ ' KiB' }} + {% elseif bytes < gigabyte %} + {{ (bytes / megabyte)|number_format(2, '.') ~ ' MiB' }} + {% elseif bytes < terabyte %} + {{ (bytes / gigabyte)|number_format(2, '.') ~ ' GiB' }} + {% else %} + {{ (bytes / terabyte)|number_format(2, '.') ~ ' TiB' }} + {% endif %} + {% endmacro %} +
+
+
+
+ + + + + + + + + + {% if currentPath != '/' %} + + + + {% endif %} + {% for item in directoryContent %} + + + + + + {% endfor %} + +
+ Name + + Date Modified + + Size +
+ Back +
+ {% if currentPath != '/' %} + {{ item.filename }} + {% else %} + {{ item.filename }} + {% endif %} + + {{ item.getMTime() | date("m/d/Y H:i:s") }} + + {% if item.isDir() %} + - + {% else %} + {{ _self.bytesToSize(item.getSize()) }} + {% endif %} +
+
+
+
+
+ + diff --git a/tests/Feature/Client/FileserverTest.php b/tests/Feature/Client/FileserverTest.php new file mode 100755 index 0000000..0595f90 --- /dev/null +++ b/tests/Feature/Client/FileserverTest.php @@ -0,0 +1,118 @@ +browser = new Browser($this->loop); + } + + public function tearDown(): void + { + parent::tearDown(); + + $this->clientFactory->getFileserver()->getSocket()->close(); + } + + /** @test */ + public function accessing_the_fileserver_works() + { + $this->shareFolder(__DIR__); + + /** @var ResponseInterface $response */ + $response = $this->await($this->browser->get('http://'.$this->fileserverUrl)); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** @test */ + public function accessing_invalid_files_returns_404() + { + $this->shareFolder(__DIR__); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage(404); + + /** @var ResponseInterface $response */ + $response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/invalid-file')); + + $this->assertSame(404, $response->getStatusCode()); + } + + /** @test */ + public function it_can_return_filtered_responses() + { + $this->shareFolder(__DIR__.'/../../fixtures', '*.md'); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage(404); + + /** @var ResponseInterface $response */ + $response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/test.txt')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('test-file'.PHP_EOL, $response->getBody()->getContents()); + } + + /** @test */ + public function it_can_return_file_responses() + { + $this->shareFolder(__DIR__.'/../../fixtures'); + + /** @var ResponseInterface $response */ + $response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/test.txt')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('test-file'.PHP_EOL, $response->getBody()->getContents()); + } + + /** @test */ + public function it_can_return_file_responses_for_valid_filtered_files() + { + $this->shareFolder(__DIR__.'/../../fixtures', '*.txt'); + + /** @var ResponseInterface $response */ + $response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/test.txt')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('test-file'.PHP_EOL, $response->getBody()->getContents()); + } + + protected function shareFolder(string $folder, string $name = '') + { + app()->singleton(Configuration::class, function ($app) { + return new Configuration('localhost', '8080', false); + }); + + $factory = (new Factory())->setLoop($this->loop); + $this->fileserverUrl = $factory->createFileServer($folder, $name); + $this->clientFactory = $factory; + } +} diff --git a/tests/fixtures/test.md b/tests/fixtures/test.md new file mode 100644 index 0000000..7d82df3 --- /dev/null +++ b/tests/fixtures/test.md @@ -0,0 +1 @@ +# Markdown diff --git a/tests/fixtures/test.txt b/tests/fixtures/test.txt new file mode 100644 index 0000000..073bdfe --- /dev/null +++ b/tests/fixtures/test.txt @@ -0,0 +1 @@ +test-file