mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Closes #12305 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
603 lines
21 KiB
PHP
603 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Project\Shared;
|
|
|
|
use App\Exceptions\ContainerShellUnavailableException;
|
|
use App\Http\Controllers\FileBrowserDownloadController;
|
|
use App\Jobs\PrepareFileDownloadJob;
|
|
use App\Models\Application;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Services\ContainerFilesystemService;
|
|
use App\Services\FileDownloadStorageService;
|
|
use App\Support\ValidationPatterns;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Component;
|
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
use Livewire\WithFileUploads;
|
|
|
|
class FileBrowser extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
use WithFileUploads;
|
|
|
|
#[Locked]
|
|
public Model $resource;
|
|
|
|
/** Uploads to S3 hold a queue worker, so each user may only start a few. */
|
|
private const MAX_PENDING_S3_DOWNLOADS = 3;
|
|
|
|
#[Locked]
|
|
public string $type = '';
|
|
|
|
#[Locked]
|
|
public bool $containerRunning = false;
|
|
|
|
#[Locked]
|
|
public bool $filesLoaded = false;
|
|
|
|
#[Locked]
|
|
public bool $loadFailed = false;
|
|
|
|
/** The container has no usable shell, so a helper container reaches its files. */
|
|
#[Locked]
|
|
public bool $useHelper = false;
|
|
|
|
#[Locked]
|
|
public bool $shellUnavailable = false;
|
|
|
|
/** Why the helper container failed, shown when the reason is known. */
|
|
#[Locked]
|
|
public ?string $helperError = null;
|
|
|
|
#[Locked]
|
|
public ?string $container = null;
|
|
|
|
/** @var array<int, string> */
|
|
#[Locked]
|
|
public array $availableContainers = [];
|
|
|
|
public string $currentPath = '';
|
|
|
|
/** @var array<int, array{name:string,type:string,size:int,mtime:int}> */
|
|
public array $entries = [];
|
|
|
|
#[Locked]
|
|
public ?string $editingPath = null;
|
|
|
|
public bool $editorOpen = false;
|
|
|
|
public string $editorContent = '';
|
|
|
|
public string $editorLanguage = '';
|
|
|
|
public ?TemporaryUploadedFile $upload = null;
|
|
|
|
/** @var array<int, string> */
|
|
#[Locked]
|
|
public array $pendingDownloads = [];
|
|
|
|
/** @var array<string, mixed> */
|
|
public array $parameters = [];
|
|
|
|
public function mount(?Model $resource = null): void
|
|
{
|
|
$this->parameters = get_route_parameters();
|
|
$resource = $resource ?? $this->resolveResourceFromRoute();
|
|
|
|
// Security gate first, before any server or container resolution.
|
|
$this->authorize('canAccessTerminal');
|
|
$this->authorize('view', $resource);
|
|
abort_unless((int) $resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403);
|
|
|
|
$this->resource = $resource;
|
|
$this->type = $this->resolveType($resource);
|
|
$this->containerRunning = (bool) $resource->isRunning();
|
|
|
|
$this->filesLoaded = ! $this->containerRunning;
|
|
}
|
|
|
|
public function loadFiles(): void
|
|
{
|
|
$this->authorizeFileAccess();
|
|
if ($this->filesLoaded) {
|
|
return;
|
|
}
|
|
$this->loadFailed = false;
|
|
$this->containerRunning = (bool) $this->resource->isRunning();
|
|
|
|
try {
|
|
$this->availableContainers = $this->containerRunning ? $this->resolveContainers() : [];
|
|
$this->container = $this->availableContainers[0] ?? null;
|
|
$this->useHelper = false;
|
|
if (is_null($this->container)) {
|
|
$this->containerRunning = false;
|
|
} else {
|
|
$this->currentPath = $this->service()->defaultRoot();
|
|
$this->loadEntries();
|
|
}
|
|
$this->filesLoaded = true;
|
|
} catch (\Throwable $e) {
|
|
$this->container = null;
|
|
$this->loadFailed = true;
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function selectContainer(string $name): void
|
|
{
|
|
$this->guard();
|
|
if (! in_array($name, $this->availableContainers, true)) {
|
|
return;
|
|
}
|
|
$this->closeEditor();
|
|
$this->container = $name;
|
|
$this->useHelper = false;
|
|
$this->currentPath = $this->service()->defaultRoot();
|
|
$this->loadEntries();
|
|
}
|
|
|
|
public function open(string $name): void
|
|
{
|
|
$this->guard();
|
|
if ($this->isHelperSymlink($name)) {
|
|
return;
|
|
}
|
|
$this->currentPath = $this->childPath($name);
|
|
$this->loadEntries();
|
|
}
|
|
|
|
public function goTo(string $path): void
|
|
{
|
|
$this->guard();
|
|
if (! str_starts_with($path, '/')) {
|
|
return;
|
|
}
|
|
$this->currentPath = $path === '/' ? '/' : rtrim($path, '/');
|
|
$this->loadEntries();
|
|
}
|
|
|
|
public function refresh(): void
|
|
{
|
|
$this->guard();
|
|
$this->loadEntries();
|
|
}
|
|
|
|
public function createDirectory(string $name): void
|
|
{
|
|
$this->guard();
|
|
try {
|
|
$this->service()->makeDirectory($this->childPath($name));
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'Folder created.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function createFile(string $name): void
|
|
{
|
|
$this->guard();
|
|
try {
|
|
$this->service()->createFile($this->childPath($name));
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'File created.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function chmodEntry(string $name, string $mode): void
|
|
{
|
|
$this->guard();
|
|
try {
|
|
$this->service()->chmod($this->childPath($name), $mode);
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'Permissions updated.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function renameEntry(string $from, string $newName): void
|
|
{
|
|
$this->guard();
|
|
try {
|
|
$this->service()->rename($this->childPath($from), $this->childPath($newName));
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'Renamed.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function deleteEntry(string $name): void
|
|
{
|
|
$this->guard();
|
|
try {
|
|
$this->service()->delete($this->childPath($name));
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'Deleted.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function openEditor(string $name): void
|
|
{
|
|
$this->guard();
|
|
if ($this->isHelperSymlink($name)) {
|
|
return;
|
|
}
|
|
$path = $this->childPath($name);
|
|
try {
|
|
// read() guards binary/oversized files internally and throws.
|
|
$this->editorContent = $this->service()->read($path);
|
|
$this->editorLanguage = $this->guessLanguage($name);
|
|
$this->editingPath = $path;
|
|
$this->editorOpen = true;
|
|
// Push content + language into the mounted Monaco editor. It lives
|
|
// behind wire:ignore, so a dispatched event is how it receives them.
|
|
$this->dispatch('load-file-editor', content: $this->editorContent, language: $this->editorLanguage);
|
|
} catch (\RuntimeException $e) {
|
|
$this->dispatch('error', "Can't edit this file - it's binary or larger than 5 MB. Download it instead.");
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function saveEditor(): void
|
|
{
|
|
$this->guard();
|
|
if (is_null($this->editingPath)) {
|
|
return;
|
|
}
|
|
try {
|
|
$this->service()->write($this->editingPath, $this->editorContent);
|
|
$this->editingPath = null;
|
|
$this->editorOpen = false;
|
|
$this->editorContent = '';
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'Saved.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function closeEditor(): void
|
|
{
|
|
$this->editingPath = null;
|
|
$this->editorOpen = false;
|
|
$this->editorContent = '';
|
|
}
|
|
|
|
public function uploadFile(): void
|
|
{
|
|
$this->guard();
|
|
$this->validate(['upload' => 'required|file|max:1048576']); // 1 GB (KB units)
|
|
try {
|
|
$localTmp = $this->upload->getRealPath();
|
|
$filename = $this->sanitizeUploadName($this->upload->getClientOriginalName());
|
|
$this->service()->upload($localTmp, $this->childPath($filename));
|
|
$this->upload = null;
|
|
$this->loadEntries();
|
|
$this->dispatch('success', 'Uploaded.');
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function download(string $name): void
|
|
{
|
|
$this->guard();
|
|
if ($this->isHelperSymlink($name)) {
|
|
return;
|
|
}
|
|
try {
|
|
$path = $this->childPath($name);
|
|
$target = $this->service()->downloadInfo($path);
|
|
$downloadName = $target['isDir'] ? $name.'.tar.gz' : $name;
|
|
$s3Storage = FileDownloadStorageService::configuredStorage();
|
|
$maxSize = $s3Storage ? FileDownloadStorageService::MAX_SIZE : ContainerFilesystemService::MAX_DOWNLOAD_SIZE;
|
|
if ($target['size'] > $maxSize) {
|
|
$this->dispatch('error', ($target['isDir'] ? 'This folder' : 'This file').' is larger than '.formatBytes($maxSize).' and cannot be downloaded here. Use the terminal or a volume backup instead.');
|
|
|
|
return;
|
|
}
|
|
|
|
if ($s3Storage) {
|
|
if (count($this->pendingDownloads) >= self::MAX_PENDING_S3_DOWNLOADS
|
|
|| ! RateLimiter::attempt('file-browser-s3-download:'.auth()->id(), 10, fn () => true, 3600)) {
|
|
$this->dispatch('error', 'Too many downloads are in progress. Wait for them to finish, then try again.');
|
|
|
|
return;
|
|
}
|
|
$downloadId = (string) Str::uuid();
|
|
$this->pendingDownloads[] = $downloadId;
|
|
PrepareFileDownloadJob::dispatch($downloadId, auth()->id(), $this->server()->id, $this->container, $path, $target['isDir'], $downloadName, $s3Storage->id, $this->useHelper);
|
|
$this->dispatch('info', 'Preparing '.$downloadName.'. The download starts when it is ready.');
|
|
|
|
return;
|
|
}
|
|
|
|
$token = Str::random(64);
|
|
Cache::put(FileBrowserDownloadController::cacheKey($token), [
|
|
'user_id' => auth()->id(),
|
|
'resource_type' => $this->resource::class,
|
|
'resource_id' => $this->resource->getKey(),
|
|
'server_id' => $this->server()->id,
|
|
'container' => $this->container,
|
|
'path' => $path,
|
|
'name' => $downloadName,
|
|
'is_dir' => $target['isDir'],
|
|
'use_helper' => $this->useHelper,
|
|
], FileBrowserDownloadController::TOKEN_TTL_SECONDS);
|
|
|
|
$this->redirect(route('project.files.download', $token));
|
|
} catch (\Throwable $e) {
|
|
handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Polled while S3 downloads are prepared. Opens each finished link once.
|
|
*/
|
|
public function checkDownloads(): void
|
|
{
|
|
$this->authorizeFileAccess();
|
|
foreach ($this->pendingDownloads as $index => $downloadId) {
|
|
$result = Cache::get(PrepareFileDownloadJob::cacheKey($downloadId));
|
|
if (! is_array($result) || ($result['user_id'] ?? null) !== auth()->id()) {
|
|
continue;
|
|
}
|
|
|
|
unset($this->pendingDownloads[$index]);
|
|
Cache::forget(PrepareFileDownloadJob::cacheKey($downloadId));
|
|
if ($result['status'] === 'ready') {
|
|
$this->dispatch('open-file-download', url: $result['url']);
|
|
} else {
|
|
$this->dispatch('error', $result['message']);
|
|
}
|
|
}
|
|
$this->pendingDownloads = array_values($this->pendingDownloads);
|
|
}
|
|
|
|
/**
|
|
* Reduce a client-supplied upload name to a single safe basename,
|
|
* stripping any directory traversal before it reaches the container.
|
|
*/
|
|
protected function sanitizeUploadName(string $name): string
|
|
{
|
|
$name = basename(str_replace('\\', '/', $name));
|
|
if ($name === '' || preg_match('/^\.+$/', $name) === 1) {
|
|
throw new \RuntimeException('Invalid file name.');
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
/**
|
|
* Map a filename to a Monaco language id for the editor. Falls back to
|
|
* well-known whole-file names so extension-less files still highlight.
|
|
*/
|
|
protected function guessLanguage(string $name): string
|
|
{
|
|
$extMap = [
|
|
'js' => 'javascript', 'mjs' => 'javascript', 'cjs' => 'javascript', 'ts' => 'typescript',
|
|
'json' => 'json', 'php' => 'php', 'py' => 'python', 'rb' => 'ruby', 'go' => 'go',
|
|
'rs' => 'rust', 'java' => 'java', 'sh' => 'shell', 'bash' => 'shell', 'zsh' => 'shell',
|
|
'yml' => 'yaml', 'yaml' => 'yaml', 'toml' => 'ini', 'ini' => 'ini', 'env' => 'ini',
|
|
'conf' => 'ini', 'cnf' => 'ini', 'md' => 'markdown', 'markdown' => 'markdown', 'html' => 'html',
|
|
'htm' => 'html', 'xml' => 'xml', 'svg' => 'xml', 'css' => 'css', 'scss' => 'scss',
|
|
'sql' => 'sql', 'dockerfile' => 'dockerfile',
|
|
];
|
|
|
|
$base = strtolower(basename($name));
|
|
|
|
// Whole-name matches for common extension-less/dotfiles.
|
|
$nameMap = [
|
|
'dockerfile' => 'dockerfile', 'containerfile' => 'dockerfile',
|
|
'.gitignore' => 'ini', '.dockerignore' => 'ini', '.editorconfig' => 'ini',
|
|
'.npmrc' => 'ini', '.gitconfig' => 'ini',
|
|
'.bashrc' => 'shell', '.bash_profile' => 'shell', '.bash_aliases' => 'shell',
|
|
'.profile' => 'shell', '.zshrc' => 'shell', '.zprofile' => 'shell',
|
|
];
|
|
if (isset($nameMap[$base])) {
|
|
return $nameMap[$base];
|
|
}
|
|
if ($base === '.env' || str_starts_with($base, '.env.')) {
|
|
return 'ini';
|
|
}
|
|
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
|
|
return $extMap[$ext] ?? '';
|
|
}
|
|
|
|
protected function childPath(string $name): string
|
|
{
|
|
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, '/') || str_contains($name, '\\') || str_contains($name, "\0")) {
|
|
throw new \InvalidArgumentException('Invalid file name.');
|
|
}
|
|
validateShellSafePath($name, 'file name');
|
|
|
|
return rtrim($this->currentPath, '/').'/'.$name;
|
|
}
|
|
|
|
protected function authorizeFileAccess(): void
|
|
{
|
|
$this->authorize('canAccessTerminal');
|
|
$this->resource->refresh();
|
|
$this->authorize('view', $this->resource);
|
|
abort_unless((int) $this->resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403);
|
|
}
|
|
|
|
protected function guard(): void
|
|
{
|
|
$this->authorizeFileAccess();
|
|
$this->type = $this->resolveType($this->resource);
|
|
$this->containerRunning = (bool) $this->resource->isRunning();
|
|
$this->availableContainers = $this->containerRunning ? $this->resolveContainers() : [];
|
|
if (! $this->containerRunning || is_null($this->container) || ! in_array($this->container, $this->availableContainers, true)) {
|
|
throw new \RuntimeException('Container is not running.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{label:string,path:string}>
|
|
*/
|
|
public function breadcrumbs(): array
|
|
{
|
|
$crumbs = [['label' => '/', 'path' => '/']];
|
|
$accumulated = '';
|
|
foreach (array_filter(explode('/', $this->currentPath)) as $segment) {
|
|
$accumulated .= '/'.$segment;
|
|
$crumbs[] = ['label' => $segment, 'path' => $accumulated];
|
|
}
|
|
|
|
return $crumbs;
|
|
}
|
|
|
|
protected function loadEntries(): void
|
|
{
|
|
$this->shellUnavailable = false;
|
|
$this->helperError = null;
|
|
try {
|
|
$entries = $this->service()->list($this->currentPath);
|
|
} catch (ContainerShellUnavailableException $e) {
|
|
if (! $this->useHelper) {
|
|
$this->useHelper = true;
|
|
$this->loadEntries();
|
|
|
|
return;
|
|
}
|
|
Log::warning('File browser helper container failed', ['container' => $this->container, 'error' => $e->getMessage()]);
|
|
$entries = [];
|
|
$this->shellUnavailable = true;
|
|
if (preg_match('/Unable to find image|failed to resolve reference|pull access denied|manifest unknown/i', $e->getMessage())) {
|
|
$this->helperError = 'The server could not pull the Coolify helper image ('.coolifyHelperImage().':'.getHelperVersion().'). Check that the server can reach the registry.';
|
|
}
|
|
}
|
|
$this->entries = array_map(fn ($e) => [
|
|
'name' => $e->name,
|
|
'type' => $e->type,
|
|
'size' => $e->size,
|
|
'mtime' => $e->mtime,
|
|
'perms' => $e->perms,
|
|
'owner' => $e->owner,
|
|
'group' => $e->group,
|
|
], $entries);
|
|
}
|
|
|
|
protected function service(): ContainerFilesystemService
|
|
{
|
|
return new ContainerFilesystemService($this->server(), $this->container, $this->useHelper);
|
|
}
|
|
|
|
/**
|
|
* The helper resolves absolute symlinks in its own file system, not in the
|
|
* container, so it would show the wrong files. It cannot change them,
|
|
* because its root file system is read-only.
|
|
*/
|
|
protected function isHelperSymlink(string $name): bool
|
|
{
|
|
if (! $this->useHelper || ! collect($this->entries)->contains(fn (array $entry) => $entry['name'] === $name && $entry['type'] === 'symlink')) {
|
|
return false;
|
|
}
|
|
$this->dispatch('error', 'Symlinks cannot be opened in a container without a shell. Open the target path instead.');
|
|
|
|
return true;
|
|
}
|
|
|
|
protected function server(): Server
|
|
{
|
|
return match ($this->type) {
|
|
'service' => $this->resource->server,
|
|
default => $this->resource->destination->server,
|
|
};
|
|
}
|
|
|
|
protected function resolveResourceFromRoute(): Model
|
|
{
|
|
$parameters = get_route_parameters();
|
|
$teamId = data_get(auth()->user()->currentTeam(), 'id');
|
|
|
|
if ($uuid = data_get($parameters, 'application_uuid')) {
|
|
return Application::ownedByCurrentTeam()->where('uuid', $uuid)->firstOrFail();
|
|
}
|
|
if ($uuid = data_get($parameters, 'service_uuid')) {
|
|
return Service::ownedByCurrentTeam()->where('uuid', $uuid)->firstOrFail();
|
|
}
|
|
if ($uuid = data_get($parameters, 'database_uuid')) {
|
|
$resource = getResourceByUuid($uuid, $teamId);
|
|
if (is_null($resource)) {
|
|
abort(404);
|
|
}
|
|
|
|
return $resource;
|
|
}
|
|
|
|
abort(404);
|
|
}
|
|
|
|
protected function resolveType(Model $resource): string
|
|
{
|
|
return match (true) {
|
|
$resource instanceof Application => 'application',
|
|
$resource instanceof Service => 'service',
|
|
default => 'database',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
protected function resolveContainers(): array
|
|
{
|
|
if ($this->type === 'service') {
|
|
$names = [];
|
|
$this->resource->applications()->get()->each(function ($application) use (&$names) {
|
|
if ($application->isRunning()) {
|
|
$names[] = $application->name.'-'.$this->resource->uuid;
|
|
}
|
|
});
|
|
$this->resource->databases()->get()->each(function ($database) use (&$names) {
|
|
if ($database->isRunning()) {
|
|
$names[] = $database->name.'-'.$this->resource->uuid;
|
|
}
|
|
});
|
|
|
|
return array_values(array_filter($names, [ValidationPatterns::class, 'isValidContainerName']));
|
|
}
|
|
|
|
if ($this->type === 'application') {
|
|
$server = $this->server();
|
|
$names = getCurrentApplicationContainerStatus($server, $this->resource, includePullrequests: false)
|
|
->filter(fn ($c) => data_get($c, 'State') === 'running')
|
|
->map(fn ($c) => data_get($c, 'Names'))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
return array_values(array_filter($names, [ValidationPatterns::class, 'isValidContainerName']));
|
|
}
|
|
|
|
// Standalone database: the container name is the resource uuid.
|
|
$name = $this->resource->uuid;
|
|
|
|
return ValidationPatterns::isValidContainerName($name) ? [$name] : [];
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.project.shared.file-browser');
|
|
}
|
|
}
|