mirror of
https://github.com/bitinflow/expose.git
synced 2026-03-13 13:35:54 +00:00
Add fileserver support
This commit is contained in:
@@ -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();
|
||||
|
||||
137
app/Client/Fileserver/ConnectionHandler.php
Normal file
137
app/Client/Fileserver/ConnectionHandler.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Client\Fileserver;
|
||||
|
||||
use App\Http\Controllers\Concerns\LoadsViews;
|
||||
use App\Http\QueryParameters;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Ratchet\ConnectionInterface;
|
||||
use React\EventLoop\LoopInterface;
|
||||
use React\Http\Response;
|
||||
use React\Stream\ReadableResourceStream;
|
||||
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Finder\Glob;
|
||||
use Symfony\Component\Finder\Iterator\FilenameFilterIterator;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
|
||||
class ConnectionHandler
|
||||
{
|
||||
use LoadsViews;
|
||||
|
||||
/** @var string */
|
||||
protected $rootFolder;
|
||||
|
||||
/** @var string */
|
||||
protected $name;
|
||||
|
||||
/** @var LoopInterface */
|
||||
protected $loop;
|
||||
|
||||
public function __construct(string $rootFolder, string $name, LoopInterface $loop)
|
||||
{
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
31
app/Client/Fileserver/Fileserver.php
Normal file
31
app/Client/Fileserver/Fileserver.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Client\Fileserver;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\EventLoop\LoopInterface;
|
||||
use React\Http\Response;
|
||||
use React\Http\Server;
|
||||
use React\Socket\Server as SocketServer;
|
||||
|
||||
class Fileserver
|
||||
{
|
||||
/** @var SocketServer */
|
||||
protected $socket;
|
||||
|
||||
public function __construct($rootFolder, $name, $port, $address, LoopInterface $loop)
|
||||
{
|
||||
$server = new Server(function (ServerRequestInterface $request) use ($rootFolder, $name, $loop) {
|
||||
return (new ConnectionHandler($rootFolder, $name, $loop))->handle($request);
|
||||
});
|
||||
|
||||
$this->socket = new SocketServer("{$address}:{$port}", $loop);
|
||||
|
||||
$server->listen($this->socket);
|
||||
}
|
||||
|
||||
public function getSocket(): SocketServer
|
||||
{
|
||||
return $this->socket;
|
||||
}
|
||||
}
|
||||
48
app/Commands/ShareFilesCommand.php
Normal file
48
app/Commands/ShareFilesCommand.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Client\Factory;
|
||||
use App\Logger\CliRequestLogger;
|
||||
use LaravelZero\Framework\Commands\Command;
|
||||
use React\EventLoop\LoopInterface;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
class ShareFilesCommand extends Command
|
||||
{
|
||||
protected $signature = 'share-files {folder=.} {--name=} {--subdomain=} {--auth=}';
|
||||
|
||||
protected $description = 'Share a local folder with a remote expose server';
|
||||
|
||||
protected function configureConnectionLogger()
|
||||
{
|
||||
app()->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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
resources/views/client/fileserver.twig
Normal file
93
resources/views/client/fileserver.twig
Normal file
@@ -0,0 +1,93 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Expose Dashboard :: {{ subdomains|join(", ") }}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/ui@latest/dist/tailwind-ui.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js"></script>
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.1.0/build/styles/github.min.css">
|
||||
<script src="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.1.0/build/highlight.min.js" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="">
|
||||
<div class="relative bg-indigo-600" style="marign-left: -1px">
|
||||
<div class="max-w-screen-xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
|
||||
<div class="pr-16 sm:text-center sm:px-16">
|
||||
<p class="font-medium text-white flex justify-center">
|
||||
<span class="inline-block font-mono">{{ directory }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="flex flex-col px-6 py-4">
|
||||
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Modified
|
||||
</th>
|
||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white">
|
||||
{% if currentPath != '/' %}
|
||||
<tr class="border-b">
|
||||
<td colspan="3" class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-mono text-gray-900">
|
||||
<a href="/{{ parentPath }}" class="text-indigo-600 font-bold hover:text-indigo-900">Back</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for item in directoryContent %}
|
||||
<tr class="{% if loop.index % 2 == 0 %} bg-gray-50 {% endif %}">
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-mono text-gray-900">
|
||||
{% if currentPath != '/' %}
|
||||
<a href="/{{ currentPath }}/{{ item.getFilename() }}" class="text-indigo-600 hover:text-indigo-900">{{ item.filename }}</a>
|
||||
{% else %}
|
||||
<a href="/{{ item.getFilename() }}" class="text-indigo-600 hover:text-indigo-900">{{ item.filename }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
|
||||
{{ item.getMTime() | date("m/d/Y H:i:s") }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
|
||||
{% if item.isDir() %}
|
||||
-
|
||||
{% else %}
|
||||
{{ _self.bytesToSize(item.getSize()) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
118
tests/Feature/Client/FileserverTest.php
Executable file
118
tests/Feature/Client/FileserverTest.php
Executable file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Client;
|
||||
|
||||
use App\Client\Configuration;
|
||||
use App\Client\Factory;
|
||||
use App\Client\Http\HttpClient;
|
||||
use App\Logger\LoggedRequest;
|
||||
use App\Logger\RequestLogger;
|
||||
use Clue\React\Buzz\Browser;
|
||||
use Clue\React\Buzz\Message\ResponseException;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use function GuzzleHttp\Psr7\str;
|
||||
use Mockery as m;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Tests\Feature\TestCase;
|
||||
|
||||
class FileserverTest extends TestCase
|
||||
{
|
||||
/** @var Browser */
|
||||
protected $browser;
|
||||
|
||||
/** @var Factory */
|
||||
protected $clientFactory;
|
||||
|
||||
/** @var string */
|
||||
protected $fileserverUrl;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
1
tests/fixtures/test.md
vendored
Normal file
1
tests/fixtures/test.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# Markdown
|
||||
1
tests/fixtures/test.txt
vendored
Normal file
1
tests/fixtures/test.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
test-file
|
||||
Reference in New Issue
Block a user