mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Merge ffacd18100 into 6bcae50e49
This commit is contained in:
commit
e76e18cf71
7 changed files with 932 additions and 0 deletions
550
app/Livewire/Project/Shared/FileBrowser.php
Normal file
550
app/Livewire/Project/Shared/FileBrowser.php
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class FileBrowser extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $selected_container = 'default';
|
||||
|
||||
public Collection $containers;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public $resource;
|
||||
|
||||
public string $type;
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
public string $currentPath = '/';
|
||||
|
||||
public array $entries = [];
|
||||
|
||||
public bool $isLoading = false;
|
||||
|
||||
public bool $showCreateFolder = false;
|
||||
|
||||
public string $newFolderName = '';
|
||||
|
||||
public $uploadFile;
|
||||
|
||||
public bool $isUploading = false;
|
||||
|
||||
private const MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->containers = collect();
|
||||
$this->servers = collect();
|
||||
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$this->type = 'application';
|
||||
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
foreach ($this->resource->additional_servers as $server) {
|
||||
if ($server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($server);
|
||||
}
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'database_uuid')) {
|
||||
$this->type = 'database';
|
||||
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
|
||||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
$this->resource = $resource;
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadContainers(): void
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
if ($server->isSwarm()) {
|
||||
$containers = collect([
|
||||
[
|
||||
'Names' => $this->resource->uuid.'_'.$this->resource->uuid,
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
|
||||
}
|
||||
foreach ($containers as $container) {
|
||||
if (data_get($container, 'State') === 'running') {
|
||||
$this->containers = $this->containers->push([
|
||||
'server' => $server,
|
||||
'container' => $container,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif (data_get($this->parameters, 'database_uuid')) {
|
||||
if ($this->resource->isRunning()) {
|
||||
$this->containers = $this->containers->push([
|
||||
'server' => $server,
|
||||
'container' => [
|
||||
'Names' => $this->resource->uuid,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->resource->applications()->get()->each(function ($application) {
|
||||
if ($application->isRunning()) {
|
||||
$this->containers->push([
|
||||
'server' => $this->resource->server,
|
||||
'container' => [
|
||||
'Names' => data_get($application, 'name').'-'.data_get($this->resource, 'uuid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
$this->resource->databases()->get()->each(function ($database) {
|
||||
if ($database->isRunning()) {
|
||||
$this->containers->push([
|
||||
'server' => $this->resource->server,
|
||||
'container' => [
|
||||
'Names' => data_get($database, 'name').'-'.data_get($this->resource, 'uuid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$this->containers = $this->containers->sortBy(fn ($container) => data_get($container, 'container.Names'));
|
||||
|
||||
if ($this->containers->count() === 1) {
|
||||
$this->selected_container = data_get($this->containers->first(), 'container.Names');
|
||||
$this->browse('/');
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedContainer(): void
|
||||
{
|
||||
if ($this->selected_container !== 'default') {
|
||||
$this->browse('/');
|
||||
}
|
||||
}
|
||||
|
||||
public function browse(string $path): void
|
||||
{
|
||||
if (! $this->validatePath($path)) {
|
||||
$this->dispatch('error', 'Invalid path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isLoading = true;
|
||||
|
||||
try {
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($path);
|
||||
|
||||
$output = instant_remote_process([
|
||||
"docker exec {$escapedContainer} ls -la {$escapedPath} 2>&1",
|
||||
], $resolved['server']);
|
||||
|
||||
$this->entries = $this->parseLsOutput($output);
|
||||
$this->currentPath = $path;
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to browse: '.$e->getMessage());
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function navigateTo(int $index): void
|
||||
{
|
||||
if (! isset($this->entries[$index]) || ! $this->entries[$index]['isDirectory']) {
|
||||
$this->dispatch('error', 'Invalid entry.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
$newPath = rtrim($this->currentPath, '/').'/'.ltrim($name, '/');
|
||||
$this->browse($newPath);
|
||||
}
|
||||
|
||||
public function navigateUp(): void
|
||||
{
|
||||
if ($this->currentPath === '/') {
|
||||
return;
|
||||
}
|
||||
$parent = dirname($this->currentPath);
|
||||
$this->browse($parent);
|
||||
}
|
||||
|
||||
public function createFolder(): void
|
||||
{
|
||||
if (empty(trim($this->newFolderName))) {
|
||||
$this->dispatch('error', 'Folder name cannot be empty.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! preg_match('/^[a-zA-Z0-9._\-]+$/', $this->newFolderName)) {
|
||||
$this->dispatch('error', 'Folder name contains invalid characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$folderPath = rtrim($this->currentPath, '/').'/'.$this->newFolderName;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($folderPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec {$escapedContainer} mkdir -p {$escapedPath}",
|
||||
], $resolved['server']);
|
||||
|
||||
$this->newFolderName = '';
|
||||
$this->showCreateFolder = false;
|
||||
$this->dispatch('success', 'Folder created.');
|
||||
$this->browse($this->currentPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to create folder: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteEntry(int $index): void
|
||||
{
|
||||
if (! isset($this->entries[$index])) {
|
||||
$this->dispatch('error', 'Invalid entry.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$entryPath = rtrim($this->currentPath, '/').'/'.$name;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($entryPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec {$escapedContainer} rm -rf {$escapedPath}",
|
||||
], $resolved['server']);
|
||||
|
||||
$this->dispatch('success', 'Deleted successfully.');
|
||||
$this->browse($this->currentPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to delete: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadFile(int $index): mixed
|
||||
{
|
||||
if (! isset($this->entries[$index]) || $this->entries[$index]['isDirectory']) {
|
||||
$this->dispatch('error', 'Invalid file.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$filePath = rtrim($this->currentPath, '/').'/'.$name;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($filePath);
|
||||
|
||||
$sizeOutput = instant_remote_process([
|
||||
"docker exec {$escapedContainer} stat -c %s {$escapedPath} 2>/dev/null || echo 0",
|
||||
], $resolved['server'], throwError: false);
|
||||
|
||||
$fileSize = (int) trim($sizeOutput);
|
||||
|
||||
if ($fileSize > self::MAX_DOWNLOAD_SIZE) {
|
||||
$this->dispatch('error', 'File is too large to download via browser (max 100MB). Use the terminal instead.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = instant_remote_process([
|
||||
"docker exec {$escapedContainer} sh -c 'base64 {$escapedPath}'",
|
||||
], $resolved['server']);
|
||||
|
||||
$decoded = base64_decode(str_replace("\n", '', $content));
|
||||
|
||||
return response()->streamDownload(function () use ($decoded) {
|
||||
echo $decoded;
|
||||
}, $name);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to download: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadToContainer(): void
|
||||
{
|
||||
if (is_null($this->uploadFile)) {
|
||||
$this->dispatch('error', 'No file selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isUploading = true;
|
||||
|
||||
try {
|
||||
$originalName = $this->uploadFile->getClientOriginalName();
|
||||
if (! preg_match('/^[a-zA-Z0-9._\- ]+$/', $originalName)) {
|
||||
$this->dispatch('error', 'File name contains invalid characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$uuid = (string) new Cuid2;
|
||||
$localPath = $this->uploadFile->store("tmp/filebrowser-{$uuid}");
|
||||
$fullLocalPath = storage_path('app/'.$localPath);
|
||||
|
||||
$remoteTmpPath = "/tmp/coolify-upload-{$uuid}";
|
||||
$containerDest = rtrim($this->currentPath, '/').'/'.$originalName;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedRemoteTmp = escapeshellarg($remoteTmpPath);
|
||||
$escapedContainerDest = escapeshellarg($containerDest);
|
||||
|
||||
instant_scp($fullLocalPath, $remoteTmpPath, $resolved['server']);
|
||||
|
||||
instant_remote_process([
|
||||
"docker cp {$escapedRemoteTmp} {$escapedContainer}:{$escapedContainerDest}",
|
||||
], $resolved['server']);
|
||||
|
||||
instant_remote_process([
|
||||
"rm -f {$escapedRemoteTmp}",
|
||||
], $resolved['server']);
|
||||
|
||||
@unlink($fullLocalPath);
|
||||
@rmdir(dirname($fullLocalPath));
|
||||
|
||||
$this->uploadFile = null;
|
||||
$this->dispatch('success', 'File uploaded.');
|
||||
$this->browse($this->currentPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to upload: '.$e->getMessage());
|
||||
} finally {
|
||||
$this->isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadFolder(int $index): mixed
|
||||
{
|
||||
if (! isset($this->entries[$index]) || ! $this->entries[$index]['isDirectory']) {
|
||||
$this->dispatch('error', 'Invalid folder.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$folderPath = rtrim($this->currentPath, '/').'/'.$name;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($folderPath);
|
||||
|
||||
$sizeOutput = instant_remote_process([
|
||||
"docker exec {$escapedContainer} sh -c 'du -sb {$escapedPath} 2>/dev/null | cut -f1 || echo 0'",
|
||||
], $resolved['server'], throwError: false);
|
||||
|
||||
$folderSize = (int) trim($sizeOutput);
|
||||
|
||||
if ($folderSize > self::MAX_DOWNLOAD_SIZE) {
|
||||
$this->dispatch('error', 'Folder is too large to download via browser (max 100MB). Use the terminal instead.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = instant_remote_process([
|
||||
"docker exec {$escapedContainer} sh -c 'tar czf - -C ".escapeshellarg(dirname($folderPath)).' '.escapeshellarg($name)." | base64'",
|
||||
], $resolved['server']);
|
||||
|
||||
$decoded = base64_decode(str_replace("\n", '', $content));
|
||||
|
||||
return response()->streamDownload(function () use ($decoded) {
|
||||
echo $decoded;
|
||||
}, $name.'.tar.gz');
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to download folder: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveContainerAndServer(): ?array
|
||||
{
|
||||
if ($this->selected_container === 'default') {
|
||||
$this->dispatch('error', 'Please select a container.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $this->selected_container)) {
|
||||
$this->dispatch('error', 'Invalid container name.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$container = collect($this->containers)->firstWhere('container.Names', $this->selected_container);
|
||||
if (is_null($container)) {
|
||||
$this->dispatch('error', 'Container not found.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = data_get($container, 'server');
|
||||
if (! $server || ! $server instanceof Server) {
|
||||
$this->dispatch('error', 'Invalid server configuration.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($server->isForceDisabled()) {
|
||||
$this->dispatch('error', 'Server is disabled.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'containerName' => data_get($container, 'container.Names'),
|
||||
'server' => $server,
|
||||
];
|
||||
}
|
||||
|
||||
private function validatePath(string $path): bool
|
||||
{
|
||||
if (! str_starts_with($path, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($path, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('/[`$|;&<>!\\\]/', $path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($path, "\0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{permissions: string, links: int, owner: string, group: string, size: int, modified: string, name: string, isDirectory: bool, isSymlink: bool, linkTarget: ?string}>
|
||||
*/
|
||||
private function parseLsOutput(?string $output): array
|
||||
{
|
||||
if (empty($output)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = explode("\n", trim($output));
|
||||
$entries = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line) || str_starts_with($line, 'total ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^([d\-lbcps][rwxsStT\-]{9})\s+(\d+)\s+(\S+)\s+(\S+)\s+([\d,]+)\s+(.{12,18})\s+(.+)$/', $line, $matches)) {
|
||||
$name = $matches[7];
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isSymlink = str_starts_with($matches[1], 'l');
|
||||
$linkTarget = null;
|
||||
if ($isSymlink && str_contains($name, ' -> ')) {
|
||||
[$name, $linkTarget] = explode(' -> ', $name, 2);
|
||||
}
|
||||
|
||||
$sizeStr = str_replace(',', '', $matches[5]);
|
||||
|
||||
$entries[] = [
|
||||
'permissions' => $matches[1],
|
||||
'links' => (int) $matches[2],
|
||||
'owner' => $matches[3],
|
||||
'group' => $matches[4],
|
||||
'size' => (int) $sizeStr,
|
||||
'modified' => trim($matches[6]),
|
||||
'name' => $name,
|
||||
'isDirectory' => str_starts_with($matches[1], 'd'),
|
||||
'isSymlink' => $isSymlink,
|
||||
'linkTarget' => $linkTarget,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($entries, function ($a, $b) {
|
||||
if ($a['isDirectory'] !== $b['isDirectory']) {
|
||||
return $b['isDirectory'] <=> $a['isDirectory'];
|
||||
}
|
||||
|
||||
return strcasecmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.file-browser');
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,10 @@
|
|||
href="{{ route('project.application.command', $parameters) }}">
|
||||
Terminal
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('project.application.file-browser') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.application.file-browser', $parameters) }}">
|
||||
Files
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
<x-applications.links :application="$application" />
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
href="{{ route('project.database.command', $parameters) }}">
|
||||
Terminal
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('project.database.file-browser') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.file-browser', $parameters) }}">
|
||||
Files
|
||||
</a>
|
||||
@endcan
|
||||
@if (
|
||||
$database->getMorphClass() === 'App\Models\StandalonePostgresql' ||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
href="{{ route('project.service.command', $parameters) }}">
|
||||
<button>Terminal</button>
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('project.service.file-browser') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.service.file-browser', $parameters) }}">
|
||||
<button>Files</button>
|
||||
</a>
|
||||
@endcan
|
||||
<x-services.links :service="$service" />
|
||||
</nav>
|
||||
|
|
|
|||
194
resources/views/livewire/project/shared/file-browser.blade.php
Normal file
194
resources/views/livewire/project/shared/file-browser.blade.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > File Browser | Coolify
|
||||
</x-slot>
|
||||
@if ($type === 'application')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<h1>File Browser</h1>
|
||||
<livewire:project.application.heading :application="$resource" />
|
||||
@elseif ($type === 'database')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<h1>File Browser</h1>
|
||||
<livewire:project.database.heading :database="$resource" />
|
||||
@elseif ($type === 'service')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="File Browser" />
|
||||
@endif
|
||||
|
||||
<h2 class="pb-4">File Browser</h2>
|
||||
|
||||
@if (count($containers) === 0)
|
||||
<div>No running containers found or terminal access is disabled on this server.</div>
|
||||
@else
|
||||
{{-- Container selector --}}
|
||||
<div class="flex gap-2 items-end pb-4">
|
||||
<x-forms.select label="Container" wire:model.live="selected_container" class="w-96">
|
||||
@foreach ($containers as $container)
|
||||
@if ($loop->first)
|
||||
<option disabled value="default">Select a container</option>
|
||||
@endif
|
||||
<option value="{{ data_get($container, 'container.Names') }}">
|
||||
{{ data_get($container, 'container.Names') }}
|
||||
({{ data_get($container, 'server.name') }})
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
@if ($selected_container !== 'default')
|
||||
{{-- Breadcrumb navigation --}}
|
||||
<div class="flex items-center gap-1 pb-4 text-sm" x-data>
|
||||
<button wire:click="browse('/')" class="dark:text-white hover:underline font-bold">/</button>
|
||||
@php
|
||||
$pathParts = array_filter(explode('/', $currentPath));
|
||||
$accumulated = '';
|
||||
@endphp
|
||||
@foreach ($pathParts as $part)
|
||||
@php $accumulated .= '/' . $part; @endphp
|
||||
<span class="dark:text-neutral-400">/</span>
|
||||
<button wire:click="browse('{{ $accumulated }}')" class="hover:underline dark:text-white">{{ $part }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="flex flex-wrap gap-2 items-center pb-4">
|
||||
<x-forms.button wire:click="navigateUp" title="Go up one directory">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 11l-5-5-5 5"/>
|
||||
<path d="M17 18l-5-5-5 5"/>
|
||||
</svg>
|
||||
Up
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="browse('{{ $currentPath }}')" title="Refresh">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
||||
</svg>
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="$set('showCreateFolder', true)" title="Create folder">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
New Folder
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
{{-- Create folder form --}}
|
||||
@if ($showCreateFolder)
|
||||
<div class="flex gap-2 items-end pb-4">
|
||||
<x-forms.input wire:model="newFolderName" label="Folder Name" placeholder="new-folder" required />
|
||||
<x-forms.button wire:click="createFolder">Create</x-forms.button>
|
||||
<x-forms.button wire:click="$set('showCreateFolder', false)">Cancel</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Upload --}}
|
||||
<div class="flex gap-2 items-end pb-4" x-data="{ uploading: false }" x-on:livewire-upload-start="uploading = true" x-on:livewire-upload-finish="uploading = false" x-on:livewire-upload-error="uploading = false">
|
||||
<div class="flex gap-2 items-end">
|
||||
<div>
|
||||
<label class="block text-sm font-medium pb-1">Upload File</label>
|
||||
<input type="file" wire:model="uploadFile" class="block text-sm file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:bg-coollabs file:text-white hover:file:bg-coollabs-100 dark:text-neutral-300" />
|
||||
</div>
|
||||
<x-forms.button wire:click="uploadToContainer" :disabled="$isUploading">
|
||||
<span x-show="!uploading && !@js($isUploading)">Upload</span>
|
||||
<span x-show="uploading || @js($isUploading)">Uploading...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loading indicator --}}
|
||||
<div wire:loading wire:target="browse,navigateTo,navigateUp,createFolder,deleteEntry,uploadToContainer" class="pb-2">
|
||||
<x-loading />
|
||||
</div>
|
||||
|
||||
{{-- File listing --}}
|
||||
<div wire:loading.remove wire:target="browse,navigateTo,navigateUp">
|
||||
@if (count($entries) === 0 && $selected_container !== 'default')
|
||||
<div class="dark:text-neutral-400">This directory is empty.</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="dark:text-neutral-400 border-b dark:border-neutral-700">
|
||||
<th class="text-left py-2 px-2">Permissions</th>
|
||||
<th class="text-left py-2 px-2">Owner</th>
|
||||
<th class="text-left py-2 px-2">Group</th>
|
||||
<th class="text-right py-2 px-2">Size</th>
|
||||
<th class="text-left py-2 px-2">Modified</th>
|
||||
<th class="text-left py-2 px-2">Name</th>
|
||||
<th class="text-right py-2 px-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($entries as $entry)
|
||||
<tr class="border-b dark:border-neutral-800 hover:dark:bg-neutral-800/50">
|
||||
<td class="py-1.5 px-2 font-mono text-xs">{{ $entry['permissions'] }}</td>
|
||||
<td class="py-1.5 px-2">{{ $entry['owner'] }}</td>
|
||||
<td class="py-1.5 px-2">{{ $entry['group'] }}</td>
|
||||
<td class="py-1.5 px-2 text-right font-mono">
|
||||
@if ($entry['isDirectory'])
|
||||
—
|
||||
@else
|
||||
{{ formatBytes($entry['size']) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-1.5 px-2 text-xs whitespace-nowrap">{{ $entry['modified'] }}</td>
|
||||
<td class="py-1.5 px-2">
|
||||
@if ($entry['isDirectory'])
|
||||
<button wire:click="navigateTo({{ $loop->index }})" class="flex items-center gap-1 hover:underline dark:text-white font-medium">
|
||||
<svg class="w-4 h-4 text-yellow-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 4a2 2 0 0 1 2-2h4.586a2 2 0 0 1 1.414.586l1.414 1.414H20a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4z"/>
|
||||
</svg>
|
||||
{{ $entry['name'] }}
|
||||
</button>
|
||||
@elseif ($entry['isSymlink'])
|
||||
<span class="flex items-center gap-1 dark:text-blue-400">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
{{ $entry['name'] }}
|
||||
@if ($entry['linkTarget'])
|
||||
<span class="dark:text-neutral-500">-> {{ $entry['linkTarget'] }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
{{ $entry['name'] }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-1.5 px-2 text-right whitespace-nowrap">
|
||||
@if ($entry['isDirectory'])
|
||||
<button wire:click="downloadFolder({{ $loop->index }})" class="text-xs hover:underline dark:text-neutral-400 hover:dark:text-white" title="Download as .tar.gz">
|
||||
Download
|
||||
</button>
|
||||
<span class="dark:text-neutral-600 px-1">|</span>
|
||||
@else
|
||||
<button wire:click="downloadFile({{ $loop->index }})" class="text-xs hover:underline dark:text-neutral-400 hover:dark:text-white" title="Download file">
|
||||
Download
|
||||
</button>
|
||||
<span class="dark:text-neutral-600 px-1">|</span>
|
||||
@endif
|
||||
<button
|
||||
x-data="{ entryName: @js($entry['name']) }"
|
||||
x-on:click="if (confirm('Delete ' + entryName + '? This cannot be undone.')) { $wire.deleteEntry({{ $loop->index }}) }"
|
||||
class="text-xs hover:underline text-error"
|
||||
title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -32,6 +32,7 @@ use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
|
|||
use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
|
||||
use App\Livewire\Project\Service\Index as ServiceIndex;
|
||||
use App\Livewire\Project\Shared\ExecuteContainerCommand;
|
||||
use App\Livewire\Project\Shared\FileBrowser;
|
||||
use App\Livewire\Project\Shared\Logs;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow;
|
||||
use App\Livewire\Project\Show as ProjectShow;
|
||||
|
|
@ -215,6 +216,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('/deployment/{deployment_uuid}', DeploymentShow::class)->name('project.application.deployment.show');
|
||||
Route::get('/logs', Logs::class)->name('project.application.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal');
|
||||
Route::get('/file-browser', FileBrowser::class)->name('project.application.file-browser')->middleware('can.access.terminal');
|
||||
Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.application.scheduled-tasks');
|
||||
});
|
||||
Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () {
|
||||
|
|
@ -232,6 +234,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
|
||||
Route::get('/logs', Logs::class)->name('project.database.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.database.command')->middleware('can.access.terminal');
|
||||
Route::get('/file-browser', FileBrowser::class)->name('project.database.file-browser')->middleware('can.access.terminal');
|
||||
Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index');
|
||||
Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution');
|
||||
});
|
||||
|
|
@ -246,6 +249,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('/tags', ServiceConfiguration::class)->name('project.service.tags');
|
||||
Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal');
|
||||
Route::get('/file-browser', FileBrowser::class)->name('project.service.file-browser')->middleware('can.access.terminal');
|
||||
Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups');
|
||||
Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource');
|
||||
Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index');
|
||||
|
|
|
|||
172
tests/Unit/Livewire/FileBrowserTest.php
Normal file
172
tests/Unit/Livewire/FileBrowserTest.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\FileBrowser;
|
||||
|
||||
test('validatePath accepts valid absolute paths', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, '/'))->toBeTrue();
|
||||
expect($method->invoke($component, '/home'))->toBeTrue();
|
||||
expect($method->invoke($component, '/var/log/app'))->toBeTrue();
|
||||
expect($method->invoke($component, '/usr/local/bin'))->toBeTrue();
|
||||
expect($method->invoke($component, '/tmp/my-file.txt'))->toBeTrue();
|
||||
expect($method->invoke($component, '/path/with spaces'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('validatePath rejects relative paths', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, 'relative/path'))->toBeFalse();
|
||||
expect($method->invoke($component, './current'))->toBeFalse();
|
||||
expect($method->invoke($component, 'file.txt'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('validatePath rejects directory traversal', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, '/path/../etc/passwd'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/..hidden'))->toBeFalse();
|
||||
expect($method->invoke($component, '/..'))->toBeFalse();
|
||||
expect($method->invoke($component, '/../../etc'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('validatePath rejects shell metacharacters', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, '/path/$(whoami)'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/`id`'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/;rm -rf /'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/|cat /etc/passwd'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/&bg'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/>output'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/<input'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/!history'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('validatePath rejects null bytes', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, "/path/\0hidden"))->toBeFalse();
|
||||
});
|
||||
|
||||
test('parseLsOutput parses standard ls output', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 48
|
||||
drwxr-xr-x 2 root root 4096 Mar 2 12:00 config
|
||||
-rw-r--r-- 1 www www 1234 Mar 1 09:30 index.html
|
||||
-rwxr-xr-x 1 root root 567 Feb 28 15:45 start.sh
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(3);
|
||||
|
||||
expect($entries[0]['name'])->toBe('config');
|
||||
expect($entries[0]['isDirectory'])->toBeTrue();
|
||||
expect($entries[0]['permissions'])->toBe('drwxr-xr-x');
|
||||
expect($entries[0]['owner'])->toBe('root');
|
||||
|
||||
expect($entries[1]['name'])->toBe('index.html');
|
||||
expect($entries[1]['isDirectory'])->toBeFalse();
|
||||
expect($entries[1]['size'])->toBe(1234);
|
||||
expect($entries[1]['owner'])->toBe('www');
|
||||
|
||||
expect($entries[2]['name'])->toBe('start.sh');
|
||||
expect($entries[2]['permissions'])->toBe('-rwxr-xr-x');
|
||||
});
|
||||
|
||||
test('parseLsOutput sorts directories first then alphabetically', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 16
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 zebra.txt
|
||||
drwxr-xr-x 2 root root 4096 Mar 1 10:00 alpha
|
||||
-rw-r--r-- 1 root root 200 Mar 1 10:00 apple.txt
|
||||
drwxr-xr-x 2 root root 4096 Mar 1 10:00 beta
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(4);
|
||||
expect($entries[0]['name'])->toBe('alpha');
|
||||
expect($entries[0]['isDirectory'])->toBeTrue();
|
||||
expect($entries[1]['name'])->toBe('beta');
|
||||
expect($entries[1]['isDirectory'])->toBeTrue();
|
||||
expect($entries[2]['name'])->toBe('apple.txt');
|
||||
expect($entries[2]['isDirectory'])->toBeFalse();
|
||||
expect($entries[3]['name'])->toBe('zebra.txt');
|
||||
expect($entries[3]['isDirectory'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('parseLsOutput skips . and .. entries', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 8
|
||||
drwxr-xr-x 3 root root 4096 Mar 1 10:00 .
|
||||
drwxr-xr-x 5 root root 4096 Mar 1 10:00 ..
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 file.txt
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(1);
|
||||
expect($entries[0]['name'])->toBe('file.txt');
|
||||
});
|
||||
|
||||
test('parseLsOutput handles symlinks', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 4
|
||||
lrwxrwxrwx 1 root root 11 Mar 1 10:00 link -> /etc/target
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 normal.txt
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(2);
|
||||
expect($entries[0]['name'])->toBe('normal.txt');
|
||||
|
||||
$symlink = collect($entries)->firstWhere('name', 'link');
|
||||
expect($symlink['isSymlink'])->toBeTrue();
|
||||
expect($symlink['linkTarget'])->toBe('/etc/target');
|
||||
});
|
||||
|
||||
test('parseLsOutput handles empty output', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
expect($method->invoke($component, ''))->toBe([]);
|
||||
expect($method->invoke($component, null))->toBe([]);
|
||||
expect($method->invoke($component, 'total 0'))->toBe([]);
|
||||
});
|
||||
|
||||
test('parseLsOutput handles files with spaces in names', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 4
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 my file name.txt
|
||||
drwxr-xr-x 2 root root 4096 Mar 1 10:00 my folder
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(2);
|
||||
expect(collect($entries)->firstWhere('isDirectory', true)['name'])->toBe('my folder');
|
||||
expect(collect($entries)->firstWhere('isDirectory', false)['name'])->toBe('my file name.txt');
|
||||
});
|
||||
Loading…
Reference in a new issue