From fe0b038f3ce1f42ee6d35790721a6187c5d2d71c Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 14 Oct 2026 18:02:44 +0530 Subject: [PATCH] feat(files): add native file browser for applications, services and databases (#11518) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Data/FileEntry.php | 43 + .../FileBrowserDownloadController.php | 53 ++ app/Jobs/DeleteFileDownloadJob.php | 32 + app/Jobs/PrepareFileDownloadJob.php | 75 ++ app/Livewire/Project/Shared/FileBrowser.php | 545 +++++++++++++ app/Livewire/Settings/Advanced.php | 49 ++ app/Models/InstanceSettings.php | 1 + app/Services/ContainerFilesystemService.php | 428 ++++++++++ app/Services/FileDownloadStorageService.php | 165 ++++ ...oad_storage_to_instance_settings_table.php | 28 + resources/css/app.css | 66 +- .../configuration-sidebar.blade.php | 18 +- .../database/configuration-sidebar.blade.php | 12 +- resources/views/components/navbar.blade.php | 9 +- resources/views/components/reicon.blade.php | 4 + .../service/configuration-sidebar.blade.php | 12 +- .../views/components/top-user-menu.blade.php | 5 +- resources/views/layouts/app.blade.php | 2 +- .../livewire/deployments-indicator.blade.php | 11 +- .../project/shared/file-browser.blade.php | 436 ++++++++++ .../livewire/settings/advanced.blade.php | 16 + routes/web.php | 6 + .../ContainerFilesystemServiceTest.php | 381 +++++++++ tests/Feature/FileDownloadStorageTest.php | 244 ++++++ tests/Feature/Livewire/FileBrowserTest.php | 770 ++++++++++++++++++ tests/v4/Browser/FileBrowserTest.php | 62 ++ 26 files changed, 3455 insertions(+), 18 deletions(-) create mode 100644 app/Data/FileEntry.php create mode 100644 app/Http/Controllers/FileBrowserDownloadController.php create mode 100644 app/Jobs/DeleteFileDownloadJob.php create mode 100644 app/Jobs/PrepareFileDownloadJob.php create mode 100644 app/Livewire/Project/Shared/FileBrowser.php create mode 100644 app/Services/ContainerFilesystemService.php create mode 100644 app/Services/FileDownloadStorageService.php create mode 100644 database/migrations/2026_10_14_105230_add_file_download_storage_to_instance_settings_table.php create mode 100644 resources/views/livewire/project/shared/file-browser.blade.php create mode 100644 tests/Feature/ContainerFilesystemServiceTest.php create mode 100644 tests/Feature/FileDownloadStorageTest.php create mode 100644 tests/Feature/Livewire/FileBrowserTest.php create mode 100644 tests/v4/Browser/FileBrowserTest.php diff --git a/app/Data/FileEntry.php b/app/Data/FileEntry.php new file mode 100644 index 0000000000..7e01793ea3 --- /dev/null +++ b/app/Data/FileEntry.php @@ -0,0 +1,43 @@ +type === 'dir'; + } + + public function isEditableCandidate(): bool + { + return ! $this->isDir(); + } + + /** + * @param array $entries + * @return array + */ + public static function sort(array $entries): array + { + usort($entries, function (self $a, self $b) { + if ($a->isDir() !== $b->isDir()) { + return $a->isDir() ? -1 : 1; + } + + return strcasecmp($a->name, $b->name); + }); + + return $entries; + } +} diff --git a/app/Http/Controllers/FileBrowserDownloadController.php b/app/Http/Controllers/FileBrowserDownloadController.php new file mode 100644 index 0000000000..17bed2392e --- /dev/null +++ b/app/Http/Controllers/FileBrowserDownloadController.php @@ -0,0 +1,53 @@ +id(), 404); + + Gate::authorize('canAccessTerminal'); + $resource = $download['resource_type']::findOrFail($download['resource_id']); + Gate::authorize('view', $resource); + abort_unless((int) $resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403); + + $filesystem = new ContainerFilesystemService(Server::findOrFail($download['server_id']), $download['container']); + + return response()->streamDownload(function () use ($filesystem, $download) { + set_time_limit(0); + $filesystem->streamDownload($download['path'], $download['is_dir'], function (string $chunk) { + echo $chunk; + if (ob_get_level() > 0) { + ob_flush(); + } + flush(); + }); + }, $download['name'], [ + 'Content-Type' => 'application/octet-stream', + // Stop nginx from buffering the response to a temp file on the Coolify host. + 'X-Accel-Buffering' => 'no', + ]); + } +} diff --git a/app/Jobs/DeleteFileDownloadJob.php b/app/Jobs/DeleteFileDownloadJob.php new file mode 100644 index 0000000000..598a2c2041 --- /dev/null +++ b/app/Jobs/DeleteFileDownloadJob.php @@ -0,0 +1,32 @@ +s3StorageId); + if ($storage && str_starts_with($this->key, FileDownloadStorageService::PREFIX)) { + $downloads->delete($storage, $this->key); + } + } +} diff --git a/app/Jobs/PrepareFileDownloadJob.php b/app/Jobs/PrepareFileDownloadJob.php new file mode 100644 index 0000000000..01a8f47769 --- /dev/null +++ b/app/Jobs/PrepareFileDownloadJob.php @@ -0,0 +1,75 @@ +whereTeamId(0)->where('is_usable', true)->find($this->s3StorageId); + if (! $storage) { + throw new \RuntimeException('The file download storage is not available.'); + } + $server = Server::findOrFail($this->serverId); + + $key = FileDownloadStorageService::newObjectKey(); + // Queue the delete first, so a partial upload is removed as well. + DeleteFileDownloadJob::dispatch($storage->id, $key)->delay(now()->addHours(FileDownloadStorageService::OBJECT_TTL_HOURS)); + + $upload = $downloads->uploadUrl($storage, $key, $this->downloadName); + (new ContainerFilesystemService($server, $this->container))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']); + + $this->store(['status' => 'ready', 'url' => $downloads->downloadUrl($storage, $key)]); + } catch (\Throwable $e) { + // The message can contain the S3 endpoint or a presigned URL; keep it in the log only. + Log::warning('File browser S3 download failed', ['download_id' => $this->downloadId, 'error' => $e->getMessage()]); + $this->store(['status' => 'failed', 'message' => 'Download failed. The file could not be uploaded to the download storage. Try again, or ask your administrator to check the storage settings.']); + } + } + + /** + * @param array $result + */ + private function store(array $result): void + { + Cache::put(self::cacheKey($this->downloadId), ['user_id' => $this->userId, ...$result], now()->addMinutes(FileDownloadStorageService::LINK_TTL_MINUTES)); + } +} diff --git a/app/Livewire/Project/Shared/FileBrowser.php b/app/Livewire/Project/Shared/FileBrowser.php new file mode 100644 index 0000000000..09f8f0c6b9 --- /dev/null +++ b/app/Livewire/Project/Shared/FileBrowser.php @@ -0,0 +1,545 @@ + */ + #[Locked] + public array $availableContainers = []; + + public string $currentPath = ''; + + /** @var array */ + public array $entries = []; + + #[Locked] + public ?string $editingPath = null; + + public bool $editorOpen = false; + + public string $editorContent = ''; + + public string $editorLanguage = ''; + + public ?TemporaryUploadedFile $upload = null; + + /** @var array */ + #[Locked] + public array $pendingDownloads = []; + + /** @var array */ + 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; + 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->currentPath = $this->service()->defaultRoot(); + $this->loadEntries(); + } + + public function open(string $name): void + { + $this->guard(); + $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(); + $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(); + 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->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'], + ], 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 + */ + 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 + { + $entries = $this->service()->list($this->currentPath); + $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); + } + + 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 + */ + 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'); + } +} diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 28cbc971d2..86060ddd3f 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -6,6 +6,7 @@ use App\Models\InstanceSettings; use App\Models\S3Storage; use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; +use App\Services\FileDownloadStorageService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -63,6 +64,8 @@ class Advanced extends Component public array $avatar_storage_options = []; + public string $file_download_storage = 'stream'; + public function rules() { return [ @@ -123,6 +126,9 @@ class Advanced extends Component 'label' => $storage->name.' (S3)', ])->all(), ]; + $this->file_download_storage = $this->settings->file_download_s3_storage_id + ? 's3:'.$this->settings->file_download_s3_storage_id + : 'stream'; } public function submit() @@ -228,6 +234,7 @@ class Advanced extends Component $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; $this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null; $this->saveAvatarStorageSetting(); + $this->saveFileDownloadStorageSetting(); $this->saveAndAudit(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { @@ -258,6 +265,48 @@ class Advanced extends Component $this->settings->avatar_s3_storage_id = $storage->id; } + /** + * Selecting a storage adds the auto-delete lifecycle rule to its bucket. + * The setting is refused when the bucket is listable by anonymous users + * or does not accept the rule. + */ + private function saveFileDownloadStorageSetting(): void + { + if ($this->file_download_storage === 'stream') { + $this->settings->file_download_s3_storage_id = null; + + return; + } + + $storageId = (int) str($this->file_download_storage)->after('s3:')->value(); + if ($storageId === $this->settings->file_download_s3_storage_id) { + return; + } + + $storage = S3Storage::query() + ->whereTeamId(0) + ->where('is_usable', true) + ->find($storageId); + + if (! $storage || $this->file_download_storage !== 's3:'.$storage->id) { + $this->file_download_storage = 'stream'; + throw new \InvalidArgumentException('The selected file download storage is not available.'); + } + + try { + $downloads = app(FileDownloadStorageService::class); + $downloads->ensureBucketIsPrivate($storage); + $downloads->ensureLifecycleRule($storage); + } catch (\RuntimeException $e) { + $this->file_download_storage = $this->settings->file_download_s3_storage_id + ? 's3:'.$this->settings->file_download_s3_storage_id + : 'stream'; + throw $e; + } + + $this->settings->file_download_s3_storage_id = $storage->id; + } + public function clearDomainConnectPrivateKey(): void { try { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 1d08078176..09b9971c6d 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -59,6 +59,7 @@ class InstanceSettings extends Model 'avatar_storage_type', 'avatar_s3_storage_id', 'image_cdn_url', + 'file_download_s3_storage_id', 'is_dashboard_force_https_enabled', 'is_passkey_enabled', ]; diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php new file mode 100644 index 0000000000..b5e2766257 --- /dev/null +++ b/app/Services/ContainerFilesystemService.php @@ -0,0 +1,428 @@ + | base64 -d` writes travel as a single execve + * argument, capped at ~128 KiB by MAX_ARG_STRLEN. base64 inflates by 4/3, + * so keep inline content below that limit with room for command quoting. + */ + private const MAX_INLINE_WRITE_SIZE = 64 * 1024; + + /** Largest file, or uncompressed folder, the file browser may download. */ + public const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024; + + public function __construct( + private Server $server, + private string $container, + ) {} + + public function buildListCommand(string $path): string + { + $escapedPath = $this->escapePath($path, 'list path'); + + // Emit the whole directory through batched stat calls (one for most folders) + // instead of a per-entry loop. %F is the human file type ("directory", + // "symbolic link", "regular file"), mapped to a token by parseListing. + // Fields: typesizemtimepermsownergroupname. + // A real tab is embedded in the format because stat's -c does NOT expand + // a backslash \t escape (unlike printf) - it would emit the literal text. + // POSIX `find ... -prune -exec {} +` lists hidden files without recursing + // and batches names, so a huge directory cannot overflow the argument + // limit like a `* .*` glob would. Names arrive as ./name; parseListing + // strips that prefix. Names containing a tab/newline are skipped (v1 scope). + $tab = "\t"; + $format = '%F'.$tab.'%s'.$tab.'%Y'.$tab.'%a'.$tab.'%U'.$tab.'%G'.$tab.'%n'; + $inner = 'cd '.$escapedPath.' 2>/dev/null || exit 0; ' + .'find . ! -name . -prune -exec stat -c "'.$format.'" -- {} + 2>/dev/null'; + + return $this->dockerExecShell($inner); + } + + /** + * @return array + */ + public function list(string $path): array + { + $raw = instant_remote_process([$this->buildListCommand($path)], $this->server, throwError: false); + + return $this->parseListing($raw); + } + + /** + * Read an editable text file in a single SSH round trip. + * + * The remote command folds the size cap, binary probe and base64 read into + * one docker exec: it prints TOOBIG or BINARY for a rejected file, otherwise + * an "OK\n" header followed by the base64 payload (empty for a 0-byte file, + * which is valid editable text). base64 keeps exact bytes safe from the + * trailing-whitespace trim in instant_remote_process. + * + * @throws \RuntimeException when the file is binary or larger than the cap + */ + public function read(string $path): string + { + $escaped = $this->escapePath($path, 'read path'); + $max = LocalFileVolume::MAX_CONTENT_SIZE; + + $cmd = '[ -f '.$escaped.' ] && [ -r '.$escaped.' ] || exit 1; ' + .'sz=$(stat -Lc %s -- '.$escaped.') || exit 1; ' + .'if [ "$sz" -gt '.$max.' ]; then echo TOOBIG; exit 0; fi; ' + .'if [ -s '.$escaped.' ] && ! grep -qI \'\' -- '.$escaped.'; then echo BINARY; exit 0; fi; ' + .'echo OK; base64 -- '.$escaped.' || exit 1'; + + $raw = (string) instant_remote_process( + [$this->dockerExecShell($cmd)], + $this->server, + ); + + $newline = strpos($raw, "\n"); + $status = $newline === false ? trim($raw) : substr($raw, 0, $newline); + if ($status === 'TOOBIG' || $status === 'BINARY') { + throw new \RuntimeException('File is not editable (binary or too large).'); + } + + $payload = $newline === false ? '' : substr($raw, $newline + 1); + + $content = base64_decode(trim($payload), true); + if ($status !== 'OK' || $content === false || strlen($content) > $max) { + throw new \RuntimeException('Could not read file.'); + } + + return $content; + } + + public function buildWriteCommand(string $path, string $content): string + { + $escaped = $this->escapePath($path, 'write path'); + $b64 = base64_encode($content); + + return $this->dockerExecShell("echo {$b64} | base64 -d > {$escaped}"); + } + + public function buildMkdirCommand(string $path): string + { + $escaped = $this->escapePath($path, 'mkdir path'); + + return $this->dockerExecShell("mkdir -p -- {$escaped}"); + } + + public function buildCreateFileCommand(string $path): string + { + $escaped = $this->escapePath($path, 'create file path'); + + // Create only if absent so an existing file is never truncated. + return $this->dockerExecShell("[ -e {$escaped} ] || : > {$escaped}"); + } + + public function buildChmodCommand(string $path, string $mode): string + { + $mode = $this->normalizeMode($mode); + $escaped = $this->escapePath($path, 'chmod path'); + + return $this->dockerExecShell("chmod {$mode} -- {$escaped}"); + } + + public function buildRenameCommand(string $from, string $to): string + { + $escapedFrom = $this->escapePath($from, 'rename source'); + $escapedTo = $this->escapePath($to, 'rename target'); + + return $this->dockerExecShell("mv -- {$escapedFrom} {$escapedTo}"); + } + + public function buildDeleteCommand(string $path): string + { + $escaped = $this->escapePath($path, 'delete path'); + + return $this->dockerExecShell("rm -rf -- {$escaped}"); + } + + public function write(string $path, string $content): void + { + if (strlen($content) > self::MAX_INLINE_WRITE_SIZE) { + $this->writeViaUpload($path, $content); + + return; + } + instant_remote_process([$this->buildWriteCommand($path, $content)], $this->server); + } + + /** + * Write content too large for a single shell argument by staging it to a + * local temp file and reusing the docker cp upload path. + */ + protected function writeViaUpload(string $path, string $content): void + { + validateShellSafePath($path, 'write path'); + $localTmp = storage_path('app/tmp/'.Str::random(16)); + @mkdir(dirname($localTmp), 0755, true); + file_put_contents($localTmp, $content); + try { + $this->upload($localTmp, $path); + } finally { + @unlink($localTmp); + } + } + + public function makeDirectory(string $path): void + { + instant_remote_process([$this->buildMkdirCommand($path)], $this->server); + } + + public function createFile(string $path): void + { + instant_remote_process([$this->buildCreateFileCommand($path)], $this->server); + } + + public function chmod(string $path, string $mode): void + { + instant_remote_process([$this->buildChmodCommand($path, $mode)], $this->server); + } + + protected function normalizeMode(string $mode): string + { + $mode = trim($mode); + if (! preg_match('/^[0-7]{3,4}$/', $mode)) { + throw new \InvalidArgumentException('Invalid permission mode. Use octal like 644 or 0755.'); + } + + return $mode; + } + + public function rename(string $from, string $to): void + { + instant_remote_process([$this->buildRenameCommand($from, $to)], $this->server); + } + + public function delete(string $path): void + { + instant_remote_process([$this->buildDeleteCommand($path)], $this->server); + } + + public function defaultRoot(): string + { + $escapedContainer = escapeshellarg($this->container); + $workingDir = instant_remote_process( + ["docker inspect --format '{{.Config.WorkingDir}}' {$escapedContainer}"], + $this->server, + throwError: false, + ); + + $workingDir = trim((string) $workingDir); + + return $workingDir !== '' ? $workingDir : '/'; + } + + /** + * @return array + */ + public function parseListing(?string $raw): array + { + if (blank($raw)) { + return []; + } + + $entries = []; + foreach (explode("\n", trim($raw)) as $line) { + if ($line === '') { + continue; + } + $parts = explode("\t", $line, 7); + $owner = ''; + $group = ''; + // Accept the current 7-field format (with owner/group) and the + // legacy 5-field (perms) and 4-field formats. + if (count($parts) === 7) { + [$type, $size, $mtime, $perms, $owner, $group, $name] = $parts; + } elseif (count($parts) === 5) { + [$type, $size, $mtime, $perms, $name] = $parts; + } elseif (count($parts) === 4) { + [$type, $size, $mtime, $name] = $parts; + $perms = ''; + } else { + continue; + } + // find prefixes names with ./; a name itself can never contain a slash. + if (str_starts_with($name, './')) { + $name = substr($name, 2); + } + if ($name === '' || $name === '.' || $name === '..') { + continue; + } + $entries[] = new FileEntry( + $name, + $this->normalizeType($type), + (int) $size, + (int) $mtime, + trim($perms), + trim($owner), + trim($group), + ); + } + + return FileEntry::sort($entries); + } + + /** + * Map a raw type field to a file/dir/symlink token. Accepts both the + * already-tokenized legacy value and stat's human %F string. + */ + protected function normalizeType(string $raw): string + { + $t = strtolower(trim($raw)); + if ($t === 'dir' || str_contains($t, 'directory')) { + return 'dir'; + } + if ($t === 'symlink' || str_contains($t, 'symbolic link')) { + return 'symlink'; + } + + return 'file'; + } + + public function upload(string $localTmpPath, string $destPath): void + { + validateShellSafePath($destPath, 'upload path'); + $serverTmp = '/tmp/coolify-upload-'.Str::random(16); + $escapedServerTmp = escapeshellarg($serverTmp); + $escapedContainer = escapeshellarg($this->container); + $escapedDest = escapeshellarg($destPath); + + try { + instant_scp($localTmpPath, $serverTmp, $this->server); + instant_remote_process( + ["docker cp {$escapedServerTmp} {$escapedContainer}:{$escapedDest}"], + $this->server, + ); + } finally { + instant_remote_process(["rm -f {$escapedServerTmp}"], $this->server, throwError: false); + } + } + + /** + * Inspect a download target in one round trip. Folder size is the + * uncompressed disk usage, so the archive is never larger than reported. + * + * @return array{isDir: bool, size: int} + * + * @throws \RuntimeException when the path does not exist + */ + public function downloadInfo(string $path): array + { + $escaped = $this->escapePath($path, 'download path'); + $cmd = 'if [ -d '.$escaped.' ]; then echo "dir $(du -sk '.$escaped.' | cut -f1)"; ' + .'elif [ -e '.$escaped.' ]; then echo "file $(stat -L -c %s '.$escaped.')"; fi'; + + $raw = trim((string) instant_remote_process([$this->dockerExecShell($cmd)], $this->server)); + [$kind, $size] = array_pad(explode(' ', $raw, 2), 2, ''); + if (! in_array($kind, ['dir', 'file'], true) || ! ctype_digit($size)) { + throw new \RuntimeException('File not found.'); + } + + return [ + 'isDir' => $kind === 'dir', + 'size' => $kind === 'dir' ? (int) $size * 1024 : (int) $size, + ]; + } + + public function buildDownloadCommand(string $path, bool $isDir): string + { + $escaped = $this->escapePath($path, 'download path'); + $escapedContainer = escapeshellarg($this->container); + + return $isDir + ? "docker exec {$escapedContainer} tar czf - -C {$escaped} ." + : "docker exec {$escapedContainer} cat -- {$escaped}"; + } + + /** + * Pipe a file, or a folder as .tar.gz, from the container to $write in + * chunks. Nothing is stored on the Coolify host or the managed server. + * + * @param callable(string): void $write + */ + public function streamDownload(string $path, bool $isDir, callable $write): void + { + $command = $this->buildDownloadCommand($path, $isDir); + if ($this->server->isNonRoot()) { + $command = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; + } + + $process = proc_open($this->sshCommand($command), [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); + if (! is_resource($process)) { + throw new \RuntimeException('Could not start the download.'); + } + + try { + while (! feof($pipes[1])) { + $chunk = fread($pipes[1], 64 * 1024); + if ($chunk === false || connection_aborted()) { + break; + } + if ($chunk !== '') { + $write($chunk); + } + } + } finally { + fclose($pipes[1]); + proc_terminate($process); + proc_close($process); + } + } + + /** + * Stage the file, or the folder as .tar.gz, on the managed server and PUT + * it to a presigned S3 URL. S3 needs a Content-Length, so a pipe without a + * known size cannot be uploaded directly. The temp file is always removed. + * + * @param array $headers + */ + public function buildS3UploadCommand(string $path, bool $isDir, string $url, array $headers): string + { + $headerArgs = collect($headers) + ->map(fn (string $value, string $name) => '-H '.escapeshellarg("{$name}: {$value}")) + ->implode(' '); + $script = 'tmp=$(mktemp /tmp/coolify-download-XXXXXXXXXX) || exit 1; ' + .'trap \'rm -f "$tmp"\' EXIT; ' + .$this->buildDownloadCommand($path, $isDir).' > "$tmp" && ' + .'curl -fsS --retry 2 '.$headerArgs.' -T "$tmp" '.escapeshellarg($url); + + return 'sh -c '.escapeshellarg($script); + } + + /** + * @param array $headers + */ + public function uploadToS3(string $path, bool $isDir, string $url, array $headers): void + { + instant_remote_process([$this->buildS3UploadCommand($path, $isDir, $url, $headers)], $this->server, timeout: 3600); + } + + protected function sshCommand(string $command): string + { + return SshMultiplexingHelper::generateSshCommand($this->server, $command); + } + + protected function escapePath(string $path, string $label): string + { + validateShellSafePath($path, $label); + + return escapeshellarg($path); + } + + protected function dockerExecShell(string $innerShell): string + { + $escapedContainer = escapeshellarg($this->container); + + return "docker exec {$escapedContainer} sh -c ".escapeshellarg($innerShell); + } +} diff --git a/app/Services/FileDownloadStorageService.php b/app/Services/FileDownloadStorageService.php new file mode 100644 index 0000000000..000453b786 --- /dev/null +++ b/app/Services/FileDownloadStorageService.php @@ -0,0 +1,165 @@ +file_download_s3_storage_id; + if (! $storageId) { + return null; + } + + return S3Storage::query()->whereTeamId(0)->where('is_usable', true)->find($storageId); + } + + public static function newObjectKey(): string + { + return self::PREFIX.Str::random(48); + } + + /** + * Add or replace the rule that deletes download objects after one day. + * S3 lifecycle rules have a one-day minimum, so this is only a safety net + * for the six-hour delete job. Other rules on the bucket are kept. + * + * @throws RuntimeException when the bucket does not accept the rule + */ + public function ensureLifecycleRule(S3Storage $storage): void + { + try { + $client = $storage->filesystem()->getClient(); + try { + $rules = $client->getBucketLifecycleConfiguration(['Bucket' => $storage->bucket])['Rules'] ?? []; + } catch (S3Exception $e) { + if ($e->getAwsErrorCode() !== 'NoSuchLifecycleConfiguration') { + throw $e; + } + $rules = []; + } + + $rules = array_values(array_filter($rules, fn (array $rule) => ($rule['ID'] ?? null) !== self::LIFECYCLE_RULE_ID)); + $rules[] = [ + 'ID' => self::LIFECYCLE_RULE_ID, + 'Filter' => ['Prefix' => self::PREFIX], + 'Status' => 'Enabled', + 'Expiration' => ['Days' => 1], + ]; + + $client->putBucketLifecycleConfiguration([ + 'Bucket' => $storage->bucket, + 'LifecycleConfiguration' => ['Rules' => $rules], + ]); + } catch (\Throwable $e) { + throw new RuntimeException('Could not add the auto-delete lifecycle rule to bucket "'.$storage->bucket.'". The S3 provider must support lifecycle rules, and the key must be allowed to change them. Error: '.$e->getMessage(), previous: $e); + } + } + + /** + * Download links are only safe while object keys stay secret. Refuse a + * bucket that lets anonymous users list its objects, and an endpoint that + * only Coolify can reach (servers upload to it, browsers download from it). + * + * @throws RuntimeException when the endpoint is internal, unreachable or listable + */ + public function ensureBucketIsPrivate(S3Storage $storage): void + { + if ($storage->trustedInternalHosts() !== []) { + throw new RuntimeException('"'.$storage->name.'" uses an internal Docker host name. Your servers and browsers cannot reach it. Use an S3 endpoint with a public or LAN address.'); + } + + try { + $this->anonymousClient($storage)->listObjectsV2(['Bucket' => $storage->bucket, 'Prefix' => self::PREFIX, 'MaxKeys' => 1]); + } catch (S3Exception $e) { + if ($e->getResponse() === null && $e->getAwsErrorCode() === null) { + throw new RuntimeException('Could not connect to "'.$storage->name.'". Check the endpoint. Your servers and browsers must reach it too. Error: '.$e->getMessage(), previous: $e); + } + + return; + } catch (\Throwable $e) { + throw new RuntimeException('Could not connect to "'.$storage->name.'". Check the endpoint. Your servers and browsers must reach it too. Error: '.$e->getMessage(), previous: $e); + } + + throw new RuntimeException('Bucket "'.$storage->bucket.'" allows anonymous listing. Make the bucket private before you use it for file downloads.'); + } + + protected function anonymousClient(S3Storage $storage): S3Client + { + return new S3Client([ + 'version' => 'latest', + 'region' => $storage->region, + 'endpoint' => $storage->endpoint, + 'use_path_style_endpoint' => true, + 'credentials' => false, + 'retries' => 0, + 'http' => array_merge(SafeWebhookUrl::httpClientOptions($storage->endpoint, $storage->trustedInternalHosts()), [ + 'connect_timeout' => 15, + 'timeout' => 15, + ]), + ]); + } + + /** + * @return array{url: string, headers: array} + */ + public function uploadUrl(S3Storage $storage, string $key, string $downloadName): array + { + $upload = $storage->filesystem()->temporaryUploadUrl($key, now()->addHour(), [ + 'ContentDisposition' => self::contentDisposition($downloadName), + ]); + + return [ + 'url' => $upload['url'], + 'headers' => collect($upload['headers']) + ->map(fn ($value) => is_array($value) ? implode(', ', $value) : (string) $value) + ->except(['Host', 'host']) + ->all(), + ]; + } + + public function downloadUrl(S3Storage $storage, string $key): string + { + return $storage->filesystem()->temporaryUrl($key, now()->addMinutes(self::LINK_TTL_MINUTES)); + } + + public function delete(S3Storage $storage, string $key): void + { + $storage->filesystem()->delete($key); + } + + public static function contentDisposition(string $name): string + { + $fallback = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?: 'download'; + + return 'attachment; filename="'.$fallback.'"; filename*=UTF-8\'\''.rawurlencode($name); + } +} diff --git a/database/migrations/2026_10_14_105230_add_file_download_storage_to_instance_settings_table.php b/database/migrations/2026_10_14_105230_add_file_download_storage_to_instance_settings_table.php new file mode 100644 index 0000000000..ff1a43faba --- /dev/null +++ b/database/migrations/2026_10_14_105230_add_file_download_storage_to_instance_settings_table.php @@ -0,0 +1,28 @@ +foreignId('file_download_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropConstrainedForeignId('file_download_s3_storage_id'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index bdd2d9e8cc..839a8514af 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2035,6 +2035,17 @@ html[data-theme="custom"] textarea:disabled { background: transparent; } +/* Main sidebar: the menu and footer scroll as one unit on short or zoomed-in + screens. Hide the scrollbar so it does not take width from the narrow + collapsed rail and push the icons aside. */ +.sidebar-nav { + scrollbar-width: none; /* Firefox */ +} + +.sidebar-nav::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + /* Unified resource heading bar: hide tab-strip scrollbars; menus/actions stay overflow-visible for dropdowns */ .resource-heading-navbar { overflow: visible; @@ -2651,6 +2662,51 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem; } +/* File browser: icon, name, perms, size, modified, actions. + Every column is a fixed width except the flexible name column, so the + header grid and each row grid resolve identical tracks and stay aligned + (an `auto` column would size to its own content per row and drift). */ +.file-table-grid { + grid-template-columns: 1.75rem minmax(10rem, 1fr) 5rem 6rem 6rem 5rem 11rem 10rem; +} + +@media (max-width: 768px) { + .data-table-header.file-table-grid { + display: none; + } + + .data-table-row.file-table-grid { + grid-template-columns: 1.75rem minmax(0, 1fr) 10rem; + gap: 0.75rem; + } + + /* Hide perms, owner, group, size and modified columns on small screens */ + .data-table-row.file-table-grid > :nth-child(3), + .data-table-row.file-table-grid > :nth-child(4), + .data-table-row.file-table-grid > :nth-child(5), + .data-table-row.file-table-grid > :nth-child(6), + .data-table-row.file-table-grid > :nth-child(7) { + display: none; + } +} + +/* File browser Monaco editor container: fixed viewport-relative height with a + rounded, hairline-framed surface that matches the rest of the settings UI. */ +.file-editor-monaco { + height: 60vh; + min-height: 320px; + overflow: hidden; + border-radius: 8px; + box-shadow: inset 0 0 0 1px var(--coollabs-line); +} + +/* Reserve the full settings sidebar width on desktop. */ +@media (min-width: 1280px) { + .files-grid { + grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr); + } +} + .environment-table-scroll { overflow-x: auto; overscroll-behavior-x: contain; @@ -5502,14 +5558,16 @@ html.dark .canvas-zoom-max { left: var(--floating-gap); } - html[data-nav-style="floating"] .app-sidebar > div > nav > .sticky { + html[data-nav-style="floating"] .app-sidebar > div > nav > .sidebar-footer { border-radius: 0 0 var(--floating-radius) var(--floating-radius); } html[data-nav-style="floating"] .app-main { margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap)); - /* The content top lines up with the sidebar and settings rail panels. */ - padding-top: var(--floating-top); + /* The content top lines up with the sidebar and settings rail panels. + The cards draw their frame as a 1px ring outside the box, while the + panels use a 1px border inside it, so add 1px to match the edges. */ + padding-top: calc(var(--floating-top) + 1px); } /* Full-bleed pages (canvases): no top padding gap, and the same gap as the panels. */ @@ -5600,7 +5658,7 @@ html.dark .canvas-zoom-max { } /* Full page width: the content starts one gap after the settings rail. */ - html[data-nav-style="floating"] .app-main-wide :has(> .application-settings-navigation) { + html[data-nav-style="floating"] .app-main-wide :is(:has(> .application-settings-navigation), .files-grid) { grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr); column-gap: var(--floating-gap); } diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 6924f0198d..5e048965ca 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -51,6 +51,12 @@ 'navigate' => false, 'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'), ], + [ + 'label' => 'Files', + 'route' => 'project.application.files', + 'active' => $currentRoute === 'project.application.files', + 'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'), + ], [ 'label' => 'Deployment Logs', 'route' => 'project.application.deployment.index', @@ -147,6 +153,7 @@ 'Persistent Storage' => 'storages', 'Backups' => 'database', 'Terminal' => 'browser-terminal', + 'Files' => 'folder', 'Deployment Logs' => 'time-back', 'Runtime Logs' => 'unordered-list', 'Git Source' => 'sources', @@ -167,7 +174,7 @@ // Discord-style groups for the settings sidebar $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Files', 'Metrics', 'Analytics'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'], @@ -278,10 +285,19 @@ 'menu-item-active' => $menuItem['active'], ]) @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif + @if ($menuItem['label'] === 'Files') + x-data="{ loading: false }" + @click="if (!$event.ctrlKey && !$event.metaKey && !$event.shiftKey && !$event.altKey) loading = true" + x-on:livewire:navigated.window="loading = false" + @endif href="{{ route($menuItem['route'], $applicationRouteParameters) }}" > {{ $menuItem['label'] }} + @if ($menuItem['label'] === 'Files') + + + @endif @if ($menuItem['badge'] ?? false) 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'], ['label' => 'Runtime Logs', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false], ['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], + ['label' => 'Files', 'route' => 'project.database.files', 'icon' => 'folder', 'visible' => auth()->user()?->can('canAccessTerminal')], ['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'], ['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'], ['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'], @@ -33,7 +34,7 @@ $menuGroups = [ 'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Metrics'], 'Deploy' => ['Servers'], 'Automation' => ['Webhooks', 'Backups', 'Import Backup'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone'], @@ -85,9 +86,18 @@ @foreach ($groupItems as $menuItem) $menuItem['active']]) @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif + @if ($menuItem['label'] === 'Files') + x-data="{ loading: false }" + @click="if (!$event.ctrlKey && !$event.metaKey && !$event.shiftKey && !$event.altKey) loading = true" + x-on:livewire:navigated.window="loading = false" + @endif href="{{ route($menuItem['route'], $databaseRouteParameters) }}"> {{ $menuItem['label'] }} + @if ($menuItem['label'] === 'Files') + + + @endif @if ($menuItem['active'] && $menuItem['route'] === 'project.database.configuration' && $pageSections !== []) diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 53a30369cd..39caf1d23e 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -171,6 +171,22 @@ @endif + + +
+ +
+ + When you select an S3 storage, Coolify adds a lifecycle rule that deletes objects under + coolify-file-downloads/ after 1 day, as a safety net. The selected + storage must be reachable from your servers, and the S3 provider must support lifecycle rules. + +
diff --git a/routes/web.php b/routes/web.php index 3cd0ca41bd..4dde2c922f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\SharedVariableCanvasController; use App\Http\Controllers\Api\TagCanvasController; use App\Http\Controllers\Controller; +use App\Http\Controllers\FileBrowserDownloadController; use App\Http\Controllers\OauthController; use App\Http\Controllers\ProfileAvatarController; use App\Http\Controllers\ProjectIconController; @@ -64,6 +65,7 @@ use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\Service\VolumeBackup\Index; use App\Livewire\Project\Service\VolumeBackup\Show; use App\Livewire\Project\Shared\ExecuteContainerCommand; +use App\Livewire\Project\Shared\FileBrowser; use App\Livewire\Project\Shared\Logs; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; @@ -363,6 +365,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/projects', ProjectIndex::class)->name('project.index'); Route::get('/projects/canvas', ProjectsCanvas::class)->name('project.canvas.index'); Route::get('/project/{project_uuid}/icon', ProjectIconController::class)->name('project.icon'); + Route::get('/files/download/{token}', FileBrowserDownloadController::class)->name('project.files.download')->middleware('can.access.terminal'); Route::prefix('project/{project_uuid}')->group(function () { Route::get('/', ProjectShow::class)->name('project.show'); Route::get('/canvas', ProjectCanvas::class)->name('project.canvas'); @@ -406,6 +409,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('/files', FileBrowser::class)->name('project.application.files')->middleware('can.access.terminal'); Route::get('/tasks/{task_uuid}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () { @@ -424,6 +428,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('/files', FileBrowser::class)->name('project.database.files')->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'); Route::get('/backups/{backup_uuid}/s3', DatabaseBackupExecution::class)->name('project.database.backup.s3'); @@ -452,6 +457,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('/files', FileBrowser::class)->name('project.service.files')->middleware('can.access.terminal'); Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}', ServiceDatabaseBackups::class)->name('project.service.database.backup.show'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/s3', ServiceDatabaseBackups::class)->name('project.service.database.backup.s3'); diff --git a/tests/Feature/ContainerFilesystemServiceTest.php b/tests/Feature/ContainerFilesystemServiceTest.php new file mode 100644 index 0000000000..3d89dfdb26 --- /dev/null +++ b/tests/Feature/ContainerFilesystemServiceTest.php @@ -0,0 +1,381 @@ + false]); +}); + +function fsService(): ContainerFilesystemService +{ + $server = Server::factory()->make(['id' => 999]); + + return new ContainerFilesystemService($server, 'app-123'); +} + +function fsServer(): Server +{ + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '203.0.113.10', + ]); +} + +it('sorts directories before files, then by name case-insensitively', function () { + $entries = [ + new FileEntry('README.md', 'file', 10, 100), + new FileEntry('src', 'dir', 0, 100), + new FileEntry('.env', 'file', 5, 100), + new FileEntry('assets', 'dir', 0, 100), + ]; + + $names = array_map(fn (FileEntry $e) => $e->name, FileEntry::sort($entries)); + + expect($names)->toBe(['assets', 'src', '.env', 'README.md']); +}); + +it('builds a listing command with a single stat call, escaped path and container name', function () { + $cmd = fsService()->buildListCommand('/var/www/html'); + + expect($cmd) + ->toContain('docker exec') + ->toContain('app-123') + ->toContain('stat -c') + ->toContain(escapeshellarg('/var/www/html')) + // Must embed a REAL tab, not a literal backslash-t: stat -c does not + // expand \t, so a literal escape would break the tab-delimited parse. + ->toContain("%F\t%s") + ->not->toContain('%F\\t%s') + // find batches names, so a huge folder cannot overflow the argument limit like a glob. + ->toContain('find . ! -name . -prune -exec stat -c') + ->not->toContain('* .*'); +}); + +it('rejects an unsafe listing path', function () { + fsService()->buildListCommand('/tmp/$(reboot)'); +})->throws(Exception::class); + +it('parses a tab-delimited listing into sorted FileEntry rows', function () { + $raw = implode("\n", [ + "file\t10\t1700000000\tREADME.md", + "dir\t0\t1700000001\tsrc", + "file\t5\t1700000002\t.env", + ]); + + $entries = fsService()->parseListing($raw); + + expect($entries)->toHaveCount(3); + expect($entries[0]->name)->toBe('src'); + expect($entries[0]->type)->toBe('dir'); + expect($entries[1]->name)->toBe('.env'); + expect($entries[2]->name)->toBe('README.md'); +}); + +it('strips the ./ prefix that find adds to listed names', function () { + $raw = implode("\n", [ + "directory\t0\t1\t755\troot\troot\t./src", + "regular file\t5\t2\t644\troot\troot\t./.env", + ]); + + $names = array_map(fn ($entry) => $entry->name, fsService()->parseListing($raw)); + + expect($names)->toBe(['src', '.env']); +}); + +it('parses an empty listing to an empty array', function () { + expect(fsService()->parseListing(null))->toBe([]); + expect(fsService()->parseListing(''))->toBe([]); +}); + +it('lists a directory by running the built command over SSH', function () { + Process::fake(['*' => Process::result(output: "dir\t0\t1\tsrc\nfile\t10\t2\tREADME.md")]); + + $server = fsServer(); + $entries = (new ContainerFilesystemService($server, 'app-123'))->list('/app'); + + expect($entries)->toHaveCount(2); + expect($entries[0]->name)->toBe('src'); +}); + +it('falls back to / when the container has no WorkingDir', function () { + Process::fake(['*' => Process::result(output: '')]); + + $server = fsServer(); + expect((new ContainerFilesystemService($server, 'app-123'))->defaultRoot())->toBe('/'); +}); + +it('refuses to read a file larger than the edit cap', function () { + // The combined read command emits TOOBIG in a single round trip. + Process::fake(['*' => Process::result(output: 'TOOBIG')]); + + $server = fsServer(); + (new ContainerFilesystemService($server, 'app-123'))->read('/app/big.bin'); +})->throws(RuntimeException::class); + +it('refuses to read a binary file', function () { + Process::fake(['*' => Process::result(output: 'BINARY')]); + + $server = fsServer(); + (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.bin'); +})->throws(RuntimeException::class); + +it('reads an editable text file in a single round trip', function () { + // "OK" header line then the base64 payload, from one docker exec. + Process::fake(['*' => Process::result(output: "OK\n".base64_encode("hello world\n"))]); + + $server = fsServer(); + $content = (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.txt'); + + expect($content)->toBe("hello world\n"); +}); + +it('treats an empty file as editable text', function () { + // Empty file => "OK" header with no payload. + Process::fake(['*' => Process::result(output: 'OK')]); + + $server = fsServer(); + + expect((new ContainerFilesystemService($server, 'app-123'))->read('/app/empty.txt'))->toBe(''); +}); + +it('writes content near or above the shell argument limit via docker cp', function (int $size) { + Process::fake(['*' => Process::result(output: '')]); + $server = fsServer(); + + (new ContainerFilesystemService($server, 'app-123'))->write('/app/big.txt', str_repeat('a', $size)); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker cp')); +})->with([96 * 1024, 200_000]); + +it('base64-encodes content in the write command', function () { + $cmd = fsService()->buildWriteCommand('/app/a.txt', "hi\n"); + + expect($cmd) + ->toContain('base64 -d') + ->toContain(base64_encode("hi\n")) + ->toContain(escapeshellarg('/app/a.txt')); +}); + +it('builds mkdir, rename and delete commands with escaped paths', function () { + $svc = fsService(); + + expect($svc->buildMkdirCommand('/app/new dir')) + ->toContain('mkdir -p') + ->toContain(escapeshellarg('/app/new dir')); + expect($svc->buildRenameCommand('/app/a', '/app/b')) + ->toContain('mv') + ->toContain(escapeshellarg('/app/a')) + ->toContain(escapeshellarg('/app/b')); + expect($svc->buildDeleteCommand('/app/x')) + ->toContain('rm -rf') + ->toContain(escapeshellarg('/app/x')); +}); + +it('rejects unsafe paths in mutating builders', function () { + fsService()->buildDeleteCommand('/app/$(reboot)'); +})->throws(Exception::class); + +it('parses the 7-field stat listing with owner and group, mapping the file type', function () { + $raw = implode("\n", [ + "directory\t4096\t1700000000\t755\troot\troot\t.", + "directory\t4096\t1700000000\t755\troot\troot\t..", + "regular file\t497\t1700000001\t644\twww-data\twww-data\t50x.html", + "directory\t4096\t1700000002\t755\tapp\tapp\tassets", + "symbolic link\t11\t1700000003\t777\troot\troot\tlink", + ]); + + $entries = fsService()->parseListing($raw); + + // The stat . and .. rows are dropped. + expect($entries)->toHaveCount(3); + expect($entries[0]->name)->toBe('assets'); + expect($entries[0]->type)->toBe('dir'); + expect($entries[0]->owner)->toBe('app'); + expect($entries[0]->group)->toBe('app'); + expect($entries[1]->name)->toBe('50x.html'); + expect($entries[1]->type)->toBe('file'); + expect($entries[1]->owner)->toBe('www-data'); + expect($entries[2]->name)->toBe('link'); + expect($entries[2]->type)->toBe('symlink'); +}); + +it('parses permissions from the 5-field listing format', function () { + $raw = "file\t497\t1700000000\t644\t50x.html\ndir\t0\t1700000001\t755\tassets"; + + $entries = fsService()->parseListing($raw); + + expect($entries[0]->name)->toBe('assets'); + expect($entries[0]->perms)->toBe('755'); + expect($entries[1]->name)->toBe('50x.html'); + expect($entries[1]->perms)->toBe('644'); +}); + +it('builds a create-file command that does not truncate an existing file', function () { + $cmd = fsService()->buildCreateFileCommand('/app/new file.txt'); + + expect($cmd) + ->toContain('[ -e') + ->toContain(': >') + ->toContain(escapeshellarg('/app/new file.txt')); +}); + +it('builds a chmod command with a validated octal mode', function () { + $cmd = fsService()->buildChmodCommand('/app/x.sh', '0755'); + + expect($cmd) + ->toContain('chmod 0755 --') + ->toContain(escapeshellarg('/app/x.sh')); +}); + +it('rejects a non-octal chmod mode', function () { + fsService()->buildChmodCommand('/app/x.sh', 'rwx'); +})->throws(InvalidArgumentException::class); + +it('rejects an unsafe path in the chmod builder', function () { + fsService()->buildChmodCommand('/app/$(reboot)', '644'); +})->throws(Exception::class); + +it('uploads by scp-ing to the server then docker cp into the container', function () { + Process::fake(['*' => Process::result(output: '')]); + $server = fsServer(); + $local = tempnam(sys_get_temp_dir(), 'up'); + file_put_contents($local, 'data'); + + (new ContainerFilesystemService($server, 'app-123'))->upload($local, '/app/plugins/x.jar'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker cp') + && str_contains($process->command, 'app-123')); + @unlink($local); +}); + +it('rejects failed or corrupt read responses', function (string $output) { + Process::fake(['*' => Process::result(output: $output)]); + (new ContainerFilesystemService(fsServer(), 'app-123'))->read('/app/a.txt'); +})->with(['', 'ERROR', "OK\n!!!"])->throws(RuntimeException::class); + +it('executes read guards for real file bytes through the non-root sudo parser', function (string $content, bool $editable) { + $server = fsServer(); + $server->update(['user' => 'deploy']); + $path = tempnam(sys_get_temp_dir(), 'fs-read-'); + file_put_contents($path, $content); + Process::fake(function ($process) { + $lines = explode("\n", $process->command); + $body = implode("\n", array_slice($lines, 1, -1)); + $local = new Symfony\Component\Process\Process([ + 'bash', '-c', + 'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body, + ]); + $local->run(); + + return Process::result(output: $local->getOutput(), errorOutput: $local->getErrorOutput(), exitCode: $local->getExitCode()); + }); + try { + $service = new ContainerFilesystemService($server, 'app-123'); + if ($editable) { + expect($service->read($path))->toBe($content); + } else { + expect(fn () => $service->read($path))->toThrow(RuntimeException::class); + } + } finally { + @unlink($path); + } +})->with([ + 'empty' => ['', true], + 'newline only' => ["\n\n", true], + 'text' => ["hello\n", true], + 'binary' => ["hello\0world", false], +]); + +it('rejects a symlink to an oversized file before reading its content', function () { + $server = fsServer(); + $path = tempnam(sys_get_temp_dir(), 'fs-size-'); + $link = $path.'-link'; + $handle = fopen($path, 'w'); + ftruncate($handle, LocalFileVolume::MAX_CONTENT_SIZE + 1); + fclose($handle); + symlink($path, $link); + Process::fake(function ($process) { + $body = implode("\n", array_slice(explode("\n", $process->command), 1, -1)); + $local = new Symfony\Component\Process\Process(['bash', '-c', 'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body]); + $local->run(); + + return Process::result(output: $local->getOutput(), exitCode: $local->getExitCode()); + }); + try { + expect(fn () => (new ContainerFilesystemService($server, 'app-123'))->read($link)) + ->toThrow(RuntimeException::class, 'File is not editable'); + } finally { + @unlink($link); + @unlink($path); + } +}); + +it('reads download type and size in one round trip', function (string $output, bool $isDir, int $size) { + Process::fake(['*' => Process::result(output: $output)]); + + $info = (new ContainerFilesystemService(fsServer(), 'app-123'))->downloadInfo('/app/target'); + + expect($info)->toBe(['isDir' => $isDir, 'size' => $size]); + Process::assertRanTimes(fn () => true, 1); +})->with([ + 'file' => ['file 497', false, 497], + 'folder in KiB' => ['dir 4', true, 4096], +]); + +it('rejects a download target that does not exist', function () { + Process::fake(['*' => Process::result(output: '')]); + + (new ContainerFilesystemService(fsServer(), 'app-123'))->downloadInfo('/app/missing'); +})->throws(RuntimeException::class, 'File not found.'); + +it('builds download commands that write to stdout instead of a temp file', function () { + $svc = fsService(); + + expect($svc->buildDownloadCommand("/app/it's.txt", false)) + ->toBe("docker exec 'app-123' cat -- ".escapeshellarg("/app/it's.txt")); + expect($svc->buildDownloadCommand('/app/dir', true)) + ->toBe("docker exec 'app-123' tar czf - -C '/app/dir' ."); +}); + +it('streams the remote command output to the writer in chunks', function () { + $server = fsServer(); + $svc = new class($server, 'app-123') extends ContainerFilesystemService + { + public ?string $remoteCommand = null; + + protected function sshCommand(string $command): string + { + $this->remoteCommand = $command; + + return 'head -c 200000 /dev/zero'; + } + }; + $received = ''; + $chunks = 0; + + $svc->streamDownload('/app/a.bin', false, function (string $chunk) use (&$received, &$chunks) { + $received .= $chunk; + $chunks++; + }); + + expect($svc->remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'"); + expect(strlen($received))->toBe(200000); + expect($chunks)->toBeGreaterThan(1); +}); diff --git a/tests/Feature/FileDownloadStorageTest.php b/tests/Feature/FileDownloadStorageTest.php new file mode 100644 index 0000000000..2cf526c1c0 --- /dev/null +++ b/tests/Feature/FileDownloadStorageTest.php @@ -0,0 +1,244 @@ + 0]); + Once::flush(); + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->storage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); +}); + +function lifecycleStorage(MockHandler $handler): S3Storage +{ + $storage = new class extends S3Storage + { + public S3Client $client; + + public function filesystem(): FilesystemAdapter + { + $adapter = Mockery::mock(AwsS3V3Adapter::class); + $adapter->shouldReceive('getClient')->andReturn($this->client); + + return $adapter; + } + }; + $storage->bucket = 'downloads'; + $storage->client = new S3Client([ + 'region' => 'us-east-1', + 'version' => 'latest', + 'credentials' => ['key' => 'k', 'secret' => 's'], + 'handler' => $handler, + ]); + + return $storage; +} + +it('adds the lifecycle rule and keeps the other bucket rules', function () { + $put = null; + $handler = new MockHandler([ + new Result(['Rules' => [ + ['ID' => 'keep-me', 'Filter' => ['Prefix' => 'logs/'], 'Status' => 'Enabled', 'Expiration' => ['Days' => 30]], + ['ID' => FileDownloadStorageService::LIFECYCLE_RULE_ID, 'Filter' => ['Prefix' => 'old/'], 'Status' => 'Enabled', 'Expiration' => ['Days' => 9]], + ]]), + function (CommandInterface $command) use (&$put) { + $put = $command->toArray(); + + return new Result([]); + }, + ]); + + (new FileDownloadStorageService)->ensureLifecycleRule(lifecycleStorage($handler)); + + expect($put['LifecycleConfiguration']['Rules'])->toBe([ + ['ID' => 'keep-me', 'Filter' => ['Prefix' => 'logs/'], 'Status' => 'Enabled', 'Expiration' => ['Days' => 30]], + ['ID' => FileDownloadStorageService::LIFECYCLE_RULE_ID, 'Filter' => ['Prefix' => FileDownloadStorageService::PREFIX], 'Status' => 'Enabled', 'Expiration' => ['Days' => 1]], + ]); +}); + +it('adds the lifecycle rule to a bucket without a lifecycle configuration', function () { + $put = null; + $handler = new MockHandler([ + fn (CommandInterface $command) => new S3Exception('none', $command, ['code' => 'NoSuchLifecycleConfiguration']), + function (CommandInterface $command) use (&$put) { + $put = $command->toArray(); + + return new Result([]); + }, + ]); + + (new FileDownloadStorageService)->ensureLifecycleRule(lifecycleStorage($handler)); + + expect($put['LifecycleConfiguration']['Rules'])->toHaveCount(1); +}); + +it('fails when the provider rejects the lifecycle rule', function () { + $handler = new MockHandler([ + new Result(['Rules' => []]), + fn (CommandInterface $command) => new S3Exception('not implemented', $command, ['code' => 'NotImplemented']), + ]); + + (new FileDownloadStorageService)->ensureLifecycleRule(lifecycleStorage($handler)); +})->throws(RuntimeException::class, 'Could not add the auto-delete lifecycle rule'); + +it('saves the download storage only after the lifecycle rule is added', function (bool $ruleAdded) { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->rootTeam]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('ensureBucketIsPrivate')->once(); + $expectation = $downloads->shouldReceive('ensureLifecycleRule')->once(); + if (! $ruleAdded) { + $expectation->andThrow(new RuntimeException('Could not add the auto-delete lifecycle rule')); + } + app()->instance(FileDownloadStorageService::class, $downloads); + + $component = Livewire::test(Advanced::class) + ->set('file_download_storage', 's3:'.$this->storage->id) + ->call('instantSave'); + + if ($ruleAdded) { + $component->assertSet('file_download_storage', 's3:'.$this->storage->id); + expect(InstanceSettings::find(0)->file_download_s3_storage_id)->toBe($this->storage->id); + } else { + $component->assertSet('file_download_storage', 'stream')->assertDispatched('error'); + expect(InstanceSettings::find(0)->file_download_s3_storage_id)->toBeNull(); + } +})->with([true, false]); + +it('builds one S3 upload script that the non-root sudo parser keeps whole', function () { + $command = (new ContainerFilesystemService(Server::factory()->make(), 'app-123')) + ->buildS3UploadCommand("/app/it's.txt", false, 'https://s3.example.com/b/k?X-Amz-Signature=abc&x=1', ['Content-Disposition' => 'attachment; filename="a.txt"']); + + expect($command)->toStartWith('sh -c ') + ->and($command)->toContain('mktemp', 'trap', 'curl -fsS', 'X-Amz-Signature=abc&x=1'); + expect(parseCommandsByLineForSudo(collect([$command]), Server::factory()->make(['user' => 'deploy']))) + ->toBe(['sudo '.$command]); +}); + +it('uploads to S3, stores the link, and queues the delete', function () { + $this->freezeTime(); + Storage::fake('ssh-keys'); + config(['constants.ssh.mux_enabled' => false]); + Bus::fake([DeleteFileDownloadJob::class]); + Process::fake(['*' => Process::result(output: '')]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]); + $server = Server::factory()->create(['team_id' => $this->rootTeam->id, 'private_key_id' => $privateKey->id]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put', 'headers' => []]); + $downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get'); + + (new PrepareFileDownloadJob('dl-1', 7, $server->id, 'app-123', '/data/a.txt', false, 'a.txt', $this->storage->id))->handle($downloads); + + expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-1')))->toBe(['user_id' => 7, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); + Process::assertRan(fn ($process) => str_contains($process->command, 'https://s3.example.com/put')); + Bus::assertDispatched(DeleteFileDownloadJob::class, fn (DeleteFileDownloadJob $job) => $job->s3StorageId === $this->storage->id + && str_starts_with($job->key, FileDownloadStorageService::PREFIX) + && $job->delay->equalTo(now()->addHours(6))); +}); + +it('makes download links that expire after 15 minutes', function () { + $this->storage->update(['endpoint' => 'https://1.1.1.1']); + $url = (new FileDownloadStorageService)->downloadUrl($this->storage, FileDownloadStorageService::PREFIX.'abc'); + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + expect((int) $query['X-Amz-Expires'])->toBeBetween(890, 900); +}); + +it('stores a failure when the upload fails', function () { + Bus::fake([DeleteFileDownloadJob::class]); + $this->storage->update(['is_usable' => false]); + + (new PrepareFileDownloadJob('dl-2', 7, 1, 'app-123', '/data/a.txt', false, 'a.txt', $this->storage->id))->handle(new FileDownloadStorageService); + + $result = Cache::get(PrepareFileDownloadJob::cacheKey('dl-2')); + expect($result)->toMatchArray(['user_id' => 7, 'status' => 'failed']) + ->and($result['message'])->not->toContain('s3.example.com', 'X-Amz'); +}); + +it('only deletes objects under the download prefix', function () { + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('delete')->once()->with(Mockery::type(S3Storage::class), FileDownloadStorageService::PREFIX.'abc'); + + (new DeleteFileDownloadJob($this->storage->id, FileDownloadStorageService::PREFIX.'abc'))->handle($downloads); + (new DeleteFileDownloadJob($this->storage->id, 'backups/db.dump'))->handle($downloads); +}); + +function anonymousListingService(MockHandler $handler): FileDownloadStorageService +{ + return new class($handler) extends FileDownloadStorageService + { + public function __construct(private MockHandler $handler) {} + + protected function anonymousClient(S3Storage $storage): S3Client + { + return new S3Client(['region' => 'us-east-1', 'version' => 'latest', 'credentials' => false, 'handler' => $this->handler]); + } + }; +} + +it('refuses a bucket that anonymous users can list', function () { + $downloads = anonymousListingService(new MockHandler([new Result(['Contents' => []])])); + + $downloads->ensureBucketIsPrivate($this->storage); +})->throws(RuntimeException::class, 'allows anonymous listing'); + +it('accepts a bucket that denies anonymous listing', function () { + $downloads = anonymousListingService(new MockHandler([ + fn (CommandInterface $command) => new S3Exception('denied', $command, ['code' => 'AccessDenied']), + ])); + + $downloads->ensureBucketIsPrivate($this->storage); + + expect(true)->toBeTrue(); +}); + +it('refuses the bundled MinIO because servers and browsers cannot reach it', function () { + $this->storage->forceFill(['uuid' => 'minio', 'endpoint' => 'http://coolify-minio:9000'])->save(); + + (new FileDownloadStorageService)->ensureBucketIsPrivate($this->storage); +})->throws(RuntimeException::class, 'uses an internal Docker host name'); + +it('explains an unreachable endpoint instead of accepting it', function () { + $downloads = anonymousListingService(new MockHandler([ + fn (CommandInterface $command) => new S3Exception('cURL error 6: Could not resolve host', $command), + ])); + + $downloads->ensureBucketIsPrivate($this->storage); +})->throws(RuntimeException::class, 'Could not connect to "Downloads"'); diff --git a/tests/Feature/Livewire/FileBrowserTest.php b/tests/Feature/Livewire/FileBrowserTest.php new file mode 100644 index 0000000000..34698719d5 --- /dev/null +++ b/tests/Feature/Livewire/FileBrowserTest.php @@ -0,0 +1,770 @@ +withoutVite(); + Storage::fake('ssh-keys'); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + + $this->team = Team::factory()->create(); + + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + $this->environment = $this->project->environments()->first(); +}); + +it('blocks non-admin members from the file browser', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->assertForbidden(); +}); + +it('shows a stopped state when the container is not running', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'exited', + ]); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->assertSet('containerRunning', false) + ->assertSee('Start the container to browse its files'); +}); + +it('loads the default directory in a deferred request for a running database', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/var/lib/postgresql')) // defaultRoot inspect + ->push(Process::result(output: "dir\t0\t1\tdata\nfile\t10\t2\tpostgresql.conf"))]); // list + + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->assertSet('containerRunning', true) + ->assertSet('currentPath', '/var/lib/postgresql') + ->assertSet('container', $database->uuid) + ->assertSee('data') + ->assertSee('postgresql.conf'); +}); + +function fbRunningDatabase($environment, $destination): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +it('creates a directory and re-lists', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: '')) // initial list (empty) + ->push(Process::result(output: '')) // mkdir + ->push(Process::result(output: "dir\t0\t1\tplugins"))]); // re-list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('createDirectory', 'plugins') + ->assertSee('plugins'); +}); + +it('creates a file and re-lists', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: '')) // initial list (empty) + ->push(Process::result(output: '')) // createFile + ->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); // re-list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('createFile', 'config.yml') + ->assertSee('config.yml'); +}); + +it('changes permissions and re-lists', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t10\t1\t644\tapp.sh")) // initial list + ->push(Process::result(output: '')) // chmod + ->push(Process::result(output: "file\t10\t1\t755\tapp.sh"))]); // re-list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->assertSee('644') + ->call('chmodEntry', 'app.sh', '755') + ->assertSee('755'); +}); + +it('sets the editor language and content from the file when opening', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t12\t1\t644\tconfig.yml")) // initial list + ->push(Process::result(output: "OK\n".base64_encode("a: 1\n")))]); // single-round-trip read + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'config.yml') + ->assertSet('editorOpen', true) + ->assertSet('editorLanguage', 'yaml') + ->assertSet('editorContent', "a: 1\n") + ->assertDispatched('load-file-editor', content: "a: 1\n", language: 'yaml'); +}); + +it('resolves a Monaco language for common extension-less files', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t12\t1\t644\tDockerfile")) // initial list + ->push(Process::result(output: "OK\n".base64_encode("FROM alpine\n")))]); // read + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'Dockerfile') + ->assertSet('editorLanguage', 'dockerfile'); +}); + +it('refuses to open a binary or oversized file in the editor', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t99999999\t1\tbig.bin")) // initial list + ->push(Process::result(output: 'TOOBIG'))]); // single-round-trip read + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'big.bin') + ->assertSet('editingPath', null); +}); + +it('rejects client changes to container access state', function (string $property, mixed $value) { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: ''))]); // initial list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->set($property, $value); +})->with([ + ['container', 'someone-elses-container'], + ['type', 'service'], + ['containerRunning', false], + ['availableContainers', ['someone-elses-container']], +])->throws(CannotUpdateLockedPropertyException::class); + +it('rechecks resource access before file operations', function (string $action, array $arguments) { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); + $otherTeam = Team::factory()->create(); + $this->project->update(['team_id' => $otherTeam->id]); + $component->call($action, ...$arguments)->assertForbidden(); +})->with([ + ['open', ['folder']], + ['goTo', ['/data']], + ['refresh', []], + ['loadFiles', []], + ['selectContainer', ['unknown']], + ['saveEditor', []], + ['deleteEntry', ['config.yml']], + ['download', ['config.yml']], +]); + +it('rejects a client attempt to replace the editor path', function () { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->set('editingPath', '/etc/passwd'); +})->throws(CannotUpdateLockedPropertyException::class); + +it('rejects unsafe child names without deleting the current folder', function (string $name) { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('deleteEntry', $name) + ->assertDispatched('error'); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'rm -rf')); +})->with(['', '.', '..', '../data', 'a/b', 'a\\b']); + +it('saves the opened file and clears editor state', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "OK\n".base64_encode('before'))) + ->push(Process::result(output: '')) + ->push(Process::result(output: ''))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', "it's.txt") + ->set('editorContent', 'after') + ->call('saveEditor') + ->assertSet('editingPath', null) + ->assertSet('editorOpen', false) + ->assertSet('editorContent', '') + ->assertDispatched('success', 'Saved.'); +}); + +it('clears the editor when selecting a container', function () { + Process::fake(['*' => Process::result(output: 'OK')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'config.yml') + ->assertSet('editorOpen', true) + ->call('selectContainer', $database->uuid) + ->assertSet('editingPath', null) + ->assertSet('editorOpen', false); +}); + +it('renames and deletes files with apostrophes', function () { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('renameEntry', "it's.txt", "that's.txt") + ->assertDispatched('success', 'Renamed.') + ->call('deleteEntry', "that's.txt") + ->assertDispatched('success', 'Deleted.'); +}); + +it('uploads a file and clears the upload state', function () { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->set('upload', UploadedFile::fake()->create("it's.txt", 1, 'text/plain')) + ->call('uploadFile') + ->assertSet('upload', null) + ->assertDispatched('success', 'Uploaded.'); +}); + +it('issues a one-time download token and redirects to the streaming route', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'dir 4'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'folder') + ->assertRedirectContains('/files/download/'); + + $token = Str::afterLast($component->effects['redirect'], '/'); + expect(Cache::get(FileBrowserDownloadController::cacheKey($token)))->toMatchArray([ + 'user_id' => $this->admin->id, + 'container' => $database->uuid, + 'path' => '/data/folder', + 'name' => 'folder.tar.gz', + 'is_dir' => true, + ]); +}); + +it('refuses downloads above the size cap', function () { + $overCapKb = intdiv(ContainerFilesystemService::MAX_DOWNLOAD_SIZE, 1024) + 1; + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "dir {$overCapKb}"))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'folder') + ->assertNoRedirect() + ->assertDispatched('error'); +}); + +it('only lets the token owner use a download token, and only once', function () { + $database = fbRunningDatabase($this->environment, $this->destination); + $download = [ + 'user_id' => $this->admin->id, + 'resource_type' => $database::class, + 'resource_id' => $database->id, + 'server_id' => $this->server->id, + 'container' => $database->uuid, + 'path' => '/data/a.txt', + 'name' => 'a.txt', + 'is_dir' => false, + ]; + $otherAdmin = User::factory()->create(); + $otherAdmin->teams()->attach($this->team, ['role' => 'admin']); + + Cache::put(FileBrowserDownloadController::cacheKey('token-a'), $download, 60); + $this->actingAs($otherAdmin)->withSession(['currentTeam' => $this->team]) + ->get(route('project.files.download', 'token-a')) + ->assertNotFound(); + expect(Cache::has(FileBrowserDownloadController::cacheKey('token-a')))->toBeFalse(); + + $this->actingAs($this->admin)->withSession(['currentTeam' => $this->team]) + ->get(route('project.files.download', 'unknown')) + ->assertNotFound(); +}); + +it('blocks members from the download route', function () { + $this->actingAs($this->member)->withSession(['currentTeam' => $this->team]) + ->get(route('project.files.download', 'any')) + ->assertForbidden(); +}); + +it('denies resources outside the current team even when the user belongs to both teams', function () { + $database = fbRunningDatabase($this->environment, $this->destination); + $otherTeam = Team::factory()->create(); + $this->member->teams()->attach($otherTeam, ['role' => 'admin']); + $this->actingAs($this->member); + session(['currentTeam' => $otherTeam]); + Process::fake(); + Livewire::test(FileBrowser::class, ['resource' => $database])->assertForbidden(); + Process::assertNothingRan(); +}); + +it('does not use a container that stopped after mounting', function (string $action, array $arguments) { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); + $database->update(['status' => 'exited']); + $component->call($action, ...$arguments); +})->with([ + ['open', ['folder']], + ['goTo', ['/data']], + ['refresh', []], +])->throws(RuntimeException::class, 'Container is not running.'); + +it('lists files for a running application and excludes stopped and preview containers', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + $containers = collect([ + ['ID' => 'base', 'Names' => 'app-base', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0'], + ['ID' => 'stopped', 'Names' => 'app-stopped', 'State' => 'exited', 'Labels' => 'coolify.pullRequestId=0'], + ['ID' => 'preview', 'Names' => 'app-preview', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=42'], + ])->map(fn ($container) => json_encode($container))->implode("\n"); + Process::fake(function ($process) use ($containers) { + $output = match (true) { + str_contains($process->command, 'docker ps') => $containers, + str_contains($process->command, 'docker inspect') => '/app', + default => "file\t12\t1\t644\tconfig.yml", + }; + + return Process::result(output: $output); + }); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->call('loadFiles') + ->assertSet('availableContainers', ['app-base']) + ->assertSet('container', 'app-base') + ->assertSet('currentPath', '/app') + ->assertSee('config.yml') + ->call('refresh') + ->assertSet('container', 'app-base') + ->assertSee('config.yml'); +}); + +it('renders before SSH starts and loads files in a later request', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]); + Process::assertNothingRan(); + $component->assertSet('filesLoaded', false) + ->assertSet('container', null) + ->call('loadFiles') + ->assertSet('filesLoaded', true) + ->assertSet('currentPath', '/data') + ->assertSee('config.yml'); +}); + +it('does not start SSH if the resource stops before deferred loading', function () { + Process::fake(); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]); + $database->update(['status' => 'exited']); + + $component->call('loadFiles') + ->assertSet('filesLoaded', true) + ->assertSet('containerRunning', false); + Process::assertNothingRan(); +}); + +it('allows retrying a failed initial container lookup', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + $fail = true; + Process::fake(function ($process) use (&$fail) { + if (str_contains($process->command, 'docker ps')) { + if ($fail) { + $fail = false; + + return Process::result(errorOutput: 'Permission denied', exitCode: 1); + } + + return Process::result(output: json_encode([ + 'ID' => 'base', 'Names' => 'app-base', 'State' => 'running', 'Labels' => '', + ])); + } + + return Process::result(output: str_contains($process->command, 'docker inspect') ? '/app' : ''); + }); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->call('loadFiles') + ->assertSet('filesLoaded', false) + ->assertSet('loadFailed', true) + ->assertSet('container', null) + ->call('loadFiles') + ->assertSet('filesLoaded', true) + ->assertSet('loadFailed', false) + ->assertSet('container', 'app-base'); +}); + +function fbConfigureDownloadStorage(): S3Storage +{ + Team::factory()->create(['id' => 0]); + $storage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $storage->id]); + Once::flush(); + + return $storage; +} + +it('queues an S3 upload instead of streaming when download storage is set', function () { + Bus::fake([PrepareFileDownloadJob::class]); + $storage = fbConfigureDownloadStorage(); + $overStreamCapKb = intdiv(ContainerFilesystemService::MAX_DOWNLOAD_SIZE, 1024) + 1; + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "dir {$overStreamCapKb}"))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'folder') + ->assertNoRedirect() + ->assertDispatched('info'); + + expect($component->get('pendingDownloads'))->toHaveCount(1); + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->downloadId === $component->get('pendingDownloads')[0] + && $job->userId === $this->admin->id + && $job->path === '/data/folder' + && $job->isDir + && $job->downloadName === 'folder.tar.gz' + && $job->s3StorageId === $storage->id); +}); + +it('refuses S3 downloads above the S3 size cap', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file '.(FileDownloadStorageService::MAX_SIZE + 1)))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'big.bin') + ->assertDispatched('error'); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('opens a finished S3 download once and ignores entries of other users', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file 10'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'a.txt'); + $downloadId = $component->get('pendingDownloads')[0]; + $cacheKey = PrepareFileDownloadJob::cacheKey($downloadId); + + Cache::put($cacheKey, ['user_id' => $this->member->id, 'status' => 'ready', 'url' => 'https://s3.example.com/other'], 60); + $component->call('checkDownloads') + ->assertNotDispatched('open-file-download') + ->assertSet('pendingDownloads', [$downloadId]); + + Cache::put($cacheKey, ['user_id' => $this->admin->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get'], 60); + $component->call('checkDownloads') + ->assertDispatched('open-file-download', url: 'https://s3.example.com/get') + ->assertSet('pendingDownloads', []); + expect(Cache::has($cacheKey))->toBeFalse(); +}); + +it('does not hand out S3 links after the user lost access to the resource', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file 10'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'a.txt'); + Cache::put(PrepareFileDownloadJob::cacheKey($component->get('pendingDownloads')[0]), ['user_id' => $this->admin->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get'], 60); + + $this->project->update(['team_id' => Team::factory()->create()->id]); + + $component->call('checkDownloads') + ->assertForbidden() + ->assertNotDispatched('open-file-download'); +}); + +it('limits how many S3 downloads a user can start', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file 10')) + ->push(Process::result(output: 'file 10')) + ->push(Process::result(output: 'file 10')) + ->push(Process::result(output: 'file 10'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); + + foreach (range(1, 4) as $attempt) { + $component->call('download', 'a.txt'); + } + + expect($component->get('pendingDownloads'))->toHaveCount(3); + Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 3); + $component->assertDispatched('error', 'Too many downloads are in progress. Wait for them to finish, then try again.'); +}); + +it('blocks navigation before a container is selected', function (string $action, array $arguments) { + Process::fake(); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]); + + expect(fn () => $component->call($action, ...$arguments)) + ->toThrow(RuntimeException::class, 'Container is not running.'); + expect($component->instance()->currentPath)->toBe(''); + Process::assertNothingRan(); +})->with([ + ['open', ['folder']], + ['goTo', ['/data']], + ['refresh', []], +]); + +it('reduces upload names to one filename component', function (string $name, string $expected) { + $component = new FileBrowser; + $sanitize = new ReflectionMethod(FileBrowser::class, 'sanitizeUploadName'); + + expect($sanitize->invoke($component, $name))->toBe($expected); +})->with([ + ['../../etc/cron.d/payload', 'payload'], + ['..\\..\\etc\\cron.d\\payload', 'payload'], + ['../nested\\folder/payload', 'payload'], + ["it's.txt", "it's.txt"], + ['.env', '.env'], + ['my file.txt', 'my file.txt'], +]); + +it('rejects empty and all-dot upload names', function (string $name) { + $component = new FileBrowser; + $sanitize = new ReflectionMethod(FileBrowser::class, 'sanitizeUploadName'); + + expect(fn () => $sanitize->invoke($component, $name)) + ->toThrow(RuntimeException::class, 'Invalid file name.'); +})->with(['', '/', '\\', '.', '..', '...', '../..', '..\\..']); + +it('refuses to save an opened file after its container identity changes', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "OK\n".base64_encode('before')))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'config.yml') + ->set('editorContent', 'after'); + + $database->update(['uuid' => (string) Str::uuid()]); + Process::swap(new Factory); + Process::fake(); + + expect(fn () => $component->call('saveEditor')) + ->toThrow(RuntimeException::class, 'Container is not running.'); + expect($component->instance()->editingPath)->toBe('/data/config.yml'); + Process::assertNothingRan(); +}); diff --git a/tests/v4/Browser/FileBrowserTest.php b/tests/v4/Browser/FileBrowserTest.php new file mode 100644 index 0000000000..39c2d9add4 --- /dev/null +++ b/tests/v4/Browser/FileBrowserTest.php @@ -0,0 +1,62 @@ +stack = seedBrowserResourceStack(); + $this->postgres = createBrowserPostgresql($this->stack, [ + 'uuid' => 'db-browser-files', + 'name' => 'Files Postgres', + 'status' => 'exited', + ]); +}); + +it('shows the Files tab and a stopped state for a stopped container', function () { + loginAndSkipBoarding(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ).'/files'; + + $page = visit($url); + + $page->assertSee('Container is not running') + ->assertSee('Start the container to browse its files') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'file-browser-stopped'); +}); + +it('opens a directory whose name contains an apostrophe', function () { + config(['constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + $this->postgres->update(['status' => 'running']); + Process::fake(function ($process) { + $output = ''; + if (str_contains($process->command, 'docker inspect')) { + $output = '/data'; + } elseif (str_contains($process->command, 'stat -c') && ! str_contains($process->command, 'folder')) { + $output = "dir\t0\t1\t755\troot\troot\tit's a folder"; + } + + return Process::result(output: $output); + }); + loginAndSkipBoarding(); + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ).'/files'; + visit($url) + ->assertSee("it's a folder") + ->click("it's a folder") + ->assertSee('This folder is empty') + ->assertSee("it's a folder") + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'file-browser-apostrophe'); +});