This commit is contained in:
Deducer 2026-03-10 11:36:08 +01:00 committed by GitHub
commit e76e18cf71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 932 additions and 0 deletions

View 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');
}
}

View file

@ -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" />

View file

@ -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' ||

View file

@ -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>

View 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'])
&mdash;
@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">-&gt; {{ $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>

View file

@ -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');

View 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');
});