mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(files): add native file browser for applications, services and databases (#11518)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
co-authored by
Andras Bacsai
parent
70f7744660
commit
fe0b038f3c
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class FileEntry
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $type,
|
||||
public int $size,
|
||||
public int $mtime,
|
||||
public string $perms = '',
|
||||
public string $owner = '',
|
||||
public string $group = '',
|
||||
) {}
|
||||
|
||||
public function isDir(): bool
|
||||
{
|
||||
return $this->type === 'dir';
|
||||
}
|
||||
|
||||
public function isEditableCandidate(): bool
|
||||
{
|
||||
return ! $this->isDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, self> $entries
|
||||
* @return array<int, self>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Streams a file browser download straight from the container to the browser.
|
||||
* Livewire download responses are buffered in memory and base64-encoded, so
|
||||
* FileBrowser::download() issues a one-time token and redirects here instead.
|
||||
*/
|
||||
class FileBrowserDownloadController extends Controller
|
||||
{
|
||||
public const TOKEN_TTL_SECONDS = 60;
|
||||
|
||||
public static function cacheKey(string $token): string
|
||||
{
|
||||
return 'file-browser-download:'.$token;
|
||||
}
|
||||
|
||||
public function __invoke(string $token): StreamedResponse
|
||||
{
|
||||
/** @var array{user_id:int,resource_type:class-string,resource_id:int,server_id:int,container:string,path:string,name:string,is_dir:bool}|null $download */
|
||||
$download = Cache::pull(self::cacheKey($token));
|
||||
abort_unless(is_array($download) && $download['user_id'] === auth()->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Services\FileDownloadStorageService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
/**
|
||||
* Deletes a file browser download from S3 when its link expires. The bucket
|
||||
* lifecycle rule removes anything this job misses after one day.
|
||||
*/
|
||||
class DeleteFileDownloadJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $s3StorageId,
|
||||
public string $key,
|
||||
) {}
|
||||
|
||||
public function handle(FileDownloadStorageService $downloads): void
|
||||
{
|
||||
$storage = S3Storage::find($this->s3StorageId);
|
||||
if ($storage && str_starts_with($this->key, FileDownloadStorageService::PREFIX)) {
|
||||
$downloads->delete($storage, $this->key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use App\Services\FileDownloadStorageService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Uploads a file browser download from the managed server to the instance
|
||||
* download S3 storage and stores a presigned link for the FileBrowser to open.
|
||||
*/
|
||||
class PrepareFileDownloadJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 3700;
|
||||
|
||||
public function __construct(
|
||||
public string $downloadId,
|
||||
public int $userId,
|
||||
public int $serverId,
|
||||
public string $container,
|
||||
public string $path,
|
||||
public bool $isDir,
|
||||
public string $downloadName,
|
||||
public int $s3StorageId,
|
||||
) {}
|
||||
|
||||
public static function cacheKey(string $downloadId): string
|
||||
{
|
||||
return 'file-browser-s3-download:'.$downloadId;
|
||||
}
|
||||
|
||||
public function handle(FileDownloadStorageService $downloads): void
|
||||
{
|
||||
try {
|
||||
$storage = S3Storage::query()->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<string, string> $result
|
||||
*/
|
||||
private function store(array $result): void
|
||||
{
|
||||
Cache::put(self::cacheKey($this->downloadId), ['user_id' => $this->userId, ...$result], now()->addMinutes(FileDownloadStorageService::LINK_TTL_MINUTES));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Http\Controllers\FileBrowserDownloadController;
|
||||
use App\Jobs\PrepareFileDownloadJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use App\Services\FileDownloadStorageService;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class FileBrowser extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use WithFileUploads;
|
||||
|
||||
#[Locked]
|
||||
public Model $resource;
|
||||
|
||||
/** Uploads to S3 hold a queue worker, so each user may only start a few. */
|
||||
private const MAX_PENDING_S3_DOWNLOADS = 3;
|
||||
|
||||
#[Locked]
|
||||
public string $type = '';
|
||||
|
||||
#[Locked]
|
||||
public bool $containerRunning = false;
|
||||
|
||||
#[Locked]
|
||||
public bool $filesLoaded = false;
|
||||
|
||||
#[Locked]
|
||||
public bool $loadFailed = false;
|
||||
|
||||
#[Locked]
|
||||
public ?string $container = null;
|
||||
|
||||
/** @var array<int, string> */
|
||||
#[Locked]
|
||||
public array $availableContainers = [];
|
||||
|
||||
public string $currentPath = '';
|
||||
|
||||
/** @var array<int, array{name:string,type:string,size:int,mtime:int}> */
|
||||
public array $entries = [];
|
||||
|
||||
#[Locked]
|
||||
public ?string $editingPath = null;
|
||||
|
||||
public bool $editorOpen = false;
|
||||
|
||||
public string $editorContent = '';
|
||||
|
||||
public string $editorLanguage = '';
|
||||
|
||||
public ?TemporaryUploadedFile $upload = null;
|
||||
|
||||
/** @var array<int, string> */
|
||||
#[Locked]
|
||||
public array $pendingDownloads = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $parameters = [];
|
||||
|
||||
public function mount(?Model $resource = null): void
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$resource = $resource ?? $this->resolveResourceFromRoute();
|
||||
|
||||
// Security gate first, before any server or container resolution.
|
||||
$this->authorize('canAccessTerminal');
|
||||
$this->authorize('view', $resource);
|
||||
abort_unless((int) $resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403);
|
||||
|
||||
$this->resource = $resource;
|
||||
$this->type = $this->resolveType($resource);
|
||||
$this->containerRunning = (bool) $resource->isRunning();
|
||||
|
||||
$this->filesLoaded = ! $this->containerRunning;
|
||||
}
|
||||
|
||||
public function loadFiles(): void
|
||||
{
|
||||
$this->authorizeFileAccess();
|
||||
if ($this->filesLoaded) {
|
||||
return;
|
||||
}
|
||||
$this->loadFailed = false;
|
||||
$this->containerRunning = (bool) $this->resource->isRunning();
|
||||
|
||||
try {
|
||||
$this->availableContainers = $this->containerRunning ? $this->resolveContainers() : [];
|
||||
$this->container = $this->availableContainers[0] ?? null;
|
||||
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<int, array{label:string,path:string}>
|
||||
*/
|
||||
public function breadcrumbs(): array
|
||||
{
|
||||
$crumbs = [['label' => '/', 'path' => '/']];
|
||||
$accumulated = '';
|
||||
foreach (array_filter(explode('/', $this->currentPath)) as $segment) {
|
||||
$accumulated .= '/'.$segment;
|
||||
$crumbs[] = ['label' => $segment, 'path' => $accumulated];
|
||||
}
|
||||
|
||||
return $crumbs;
|
||||
}
|
||||
|
||||
protected function loadEntries(): void
|
||||
{
|
||||
$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<int, string>
|
||||
*/
|
||||
protected function resolveContainers(): array
|
||||
{
|
||||
if ($this->type === 'service') {
|
||||
$names = [];
|
||||
$this->resource->applications()->get()->each(function ($application) use (&$names) {
|
||||
if ($application->isRunning()) {
|
||||
$names[] = $application->name.'-'.$this->resource->uuid;
|
||||
}
|
||||
});
|
||||
$this->resource->databases()->get()->each(function ($database) use (&$names) {
|
||||
if ($database->isRunning()) {
|
||||
$names[] = $database->name.'-'.$this->resource->uuid;
|
||||
}
|
||||
});
|
||||
|
||||
return array_values(array_filter($names, [ValidationPatterns::class, 'isValidContainerName']));
|
||||
}
|
||||
|
||||
if ($this->type === 'application') {
|
||||
$server = $this->server();
|
||||
$names = getCurrentApplicationContainerStatus($server, $this->resource, includePullrequests: false)
|
||||
->filter(fn ($c) => data_get($c, 'State') === 'running')
|
||||
->map(fn ($c) => data_get($c, 'Names'))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return array_values(array_filter($names, [ValidationPatterns::class, 'isValidContainerName']));
|
||||
}
|
||||
|
||||
// Standalone database: the container name is the resource uuid.
|
||||
$name = $this->resource->uuid;
|
||||
|
||||
return ValidationPatterns::isValidContainerName($name) ? [$name] : [];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.file-browser');
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\FileEntry;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ContainerFilesystemService
|
||||
{
|
||||
/**
|
||||
* Inline `echo <base64> | 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: type<TAB>size<TAB>mtime<TAB>perms<TAB>owner<TAB>group<TAB>name.
|
||||
// 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<int, FileEntry>
|
||||
*/
|
||||
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<int, FileEntry>
|
||||
*/
|
||||
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<string, string> $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<string, string> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Aws\S3\Exception\S3Exception;
|
||||
use Aws\S3\S3Client;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Optional S3 path for file browser downloads. The managed server uploads
|
||||
* straight to S3 with a presigned URL and the browser downloads from S3, so
|
||||
* the file never passes through the Coolify host.
|
||||
*/
|
||||
class FileDownloadStorageService
|
||||
{
|
||||
public const PREFIX = 'coolify-file-downloads/';
|
||||
|
||||
public const LIFECYCLE_RULE_ID = 'coolify-file-downloads';
|
||||
|
||||
/**
|
||||
* S3 checks the link time when a download starts, so a download that has
|
||||
* started finishes after the link expires. Nobody can start a new one.
|
||||
*/
|
||||
public const LINK_TTL_MINUTES = 15;
|
||||
|
||||
/** An expired object cannot be downloaded, so it can stay until the delete job runs. */
|
||||
public const OBJECT_TTL_HOURS = 6;
|
||||
|
||||
/** A single S3 PUT accepts at most 5 GiB. */
|
||||
public const MAX_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
public static function configuredStorage(): ?S3Storage
|
||||
{
|
||||
$storageId = instanceSettings()->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<string, string>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+62
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) }}"
|
||||
>
|
||||
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['label'] === 'Files')
|
||||
<span x-show="loading" x-cloak><x-loading compact /></span>
|
||||
<x-beta-badge class="ml-auto shrink-0" />
|
||||
@endif
|
||||
@if ($menuItem['badge'] ?? false)
|
||||
<span class="shrink-0">
|
||||
<livewire:project.application.server-status-badge :application="$application"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
['label' => '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)
|
||||
<a @class(['menu-item', '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'], $databaseRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['label'] === 'Files')
|
||||
<span x-show="loading" x-cloak><x-loading compact /></span>
|
||||
<x-beta-badge class="ml-auto shrink-0" />
|
||||
@endif
|
||||
</a>
|
||||
@if ($menuItem['active'] && $menuItem['route'] === 'project.database.configuration' && $pageSections !== [])
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@props(['deploymentsIndicator' => false])
|
||||
|
||||
<nav class="flex flex-col flex-1 bg-white border-r border-neutral-200 dark:border-white/[0.06] dark:bg-panel pt-2"
|
||||
<nav class="sidebar-nav flex min-h-0 flex-col flex-1 overflow-y-auto bg-white border-r border-neutral-200 dark:border-white/[0.06] dark:bg-panel pt-2"
|
||||
:class="collapsed ? 'px-2 lg:px-3 sidebar-collapsed' : 'px-2 lg:px-3'"
|
||||
@mouseover="
|
||||
if (!collapsed) return;
|
||||
@@ -73,7 +73,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<ul role="list" class="-mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2 scrollbar">
|
||||
<ul role="list" class="-mx-1 flex flex-1 flex-col gap-y-0.5 px-1 pb-2">
|
||||
@if (isSubscribed() || !isCloud())
|
||||
{{-- Workspace --}}
|
||||
<li class="nav-section" :class="collapsed && 'lg:hidden'">Workspace</li>
|
||||
@@ -264,8 +264,9 @@
|
||||
@endif
|
||||
@endif
|
||||
</ul>
|
||||
{{-- Sticky sidebar footer (desktop only; mobile uses a temporary slide-over) --}}
|
||||
<div class="sticky bottom-0 mt-auto -mx-2 hidden flex-col gap-1 bg-white px-2 py-2 dark:bg-panel lg:-mx-3 lg:flex lg:px-3">
|
||||
{{-- Sidebar footer (desktop only; mobile uses a temporary slide-over). It scrolls with the
|
||||
menu so no item hides behind it on short or zoomed-in screens. --}}
|
||||
<div class="sidebar-footer mt-auto -mx-2 hidden flex-col gap-1 bg-white px-2 py-2 dark:bg-panel lg:-mx-3 lg:flex lg:px-3">
|
||||
@if ($deploymentsIndicator)
|
||||
<livewire:deployments-indicator variant="sidebar" />
|
||||
@endif
|
||||
|
||||
@@ -58,17 +58,21 @@
|
||||
'unordered-list' => '<g transform="scale(1.33333)"><circle cx="3.75" cy="5.25" r="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle><circle cx="3.75" cy="12.75" r="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle><line x1="8.75" y1="5.25" x2="16.25" y2="5.25" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></line><line x1="8.75" y1="12.75" x2="16.25" y2="12.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></line></g>',
|
||||
'file' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M12.25 2.83422C11.7896 2.75598 11.162 2.75005 10.0298 2.75005C8.11311 2.75005 6.75075 2.75163 5.71785 2.88987C4.70596 3.0253 4.12453 3.27933 3.7019 3.70195C3.27869 4.12516 3.02502 4.70481 2.88976 5.7109C2.75159 6.73856 2.75 8.09323 2.75 10.0001V14.0001C2.75 15.9069 2.75159 17.2615 2.88976 18.2892C3.02502 19.2953 3.27869 19.8749 3.7019 20.2981C4.12511 20.7214 4.70476 20.975 5.71085 21.1103C6.73851 21.2485 8.09318 21.2501 10 21.2501H14C15.9068 21.2501 17.2615 21.2485 18.2892 21.1103C19.2952 20.975 19.8749 20.7214 20.2981 20.2981C20.7213 19.8749 20.975 19.2953 21.1102 18.2892C21.2484 17.2615 21.25 15.9069 21.25 14.0001V13.5629C21.25 12.0269 21.2392 11.2988 21.0762 10.7501H17.9463C16.8135 10.7501 15.8877 10.7501 15.1569 10.6518C14.3929 10.5491 13.7306 10.3268 13.2019 9.79815C12.6732 9.26945 12.4509 8.60712 12.3482 7.84317C12.25 7.1123 12.25 6.18657 12.25 5.05374V2.83422ZM13.75 3.6095V5.00005C13.75 6.19976 13.7516 7.0241 13.8348 7.64329C13.9152 8.24091 14.059 8.53395 14.2626 8.73749C14.4661 8.94103 14.7591 9.08486 15.3568 9.16521C15.976 9.24846 16.8003 9.25005 18 9.25005H20.0195C19.723 8.9625 19.3432 8.61797 18.85 8.17407L14.8912 4.61117C14.4058 4.17433 14.0446 3.85187 13.75 3.6095ZM10.1755 1.25002C11.5601 1.24965 12.4546 1.24942 13.2779 1.56535C14.1012 1.88129 14.7632 2.47735 15.7873 3.39955C15.8226 3.43139 15.8584 3.46361 15.8947 3.49623L19.8534 7.05912C19.8956 7.09705 19.9372 7.1345 19.9783 7.17149C21.162 8.23614 21.9274 8.92458 22.3391 9.84902C22.7508 10.7734 22.7505 11.8029 22.75 13.3949C22.75 13.4502 22.75 13.5062 22.75 13.5629V14.0565C22.75 15.8942 22.75 17.3499 22.5969 18.4891C22.4392 19.6615 22.1071 20.6104 21.3588 21.3588C20.6104 22.1072 19.6614 22.4393 18.489 22.5969C17.3498 22.7501 15.8942 22.7501 14.0564 22.7501H9.94359C8.10583 22.7501 6.65019 22.7501 5.51098 22.5969C4.33856 22.4393 3.38961 22.1072 2.64124 21.3588C1.89288 20.6104 1.56076 19.6615 1.40314 18.4891C1.24997 17.3499 1.24998 15.8942 1.25 14.0565V9.94363C1.24998 8.10587 1.24997 6.65024 1.40314 5.51103C1.56076 4.33861 1.89288 3.38966 2.64124 2.64129C3.39019 1.89235 4.34232 1.56059 5.51887 1.40313C6.66283 1.25002 8.1257 1.25003 9.97352 1.25005L10.0298 1.25005C10.0789 1.25005 10.1275 1.25004 10.1755 1.25002Z" fill="currentColor"/>',
|
||||
'file-content' => '<g transform="scale(1.33333)"><line x1="5.75" y1="6.75" x2="7.75" y2="6.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></line><line x1="5.75" y1="9.75" x2="12.25" y2="9.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></line><line x1="5.75" y1="12.75" x2="12.25" y2="12.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></line><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></g>',
|
||||
'folder-filled' => '<path d="M2 6.5A2.5 2.5 0 0 1 4.5 4h4.38c.53 0 1.04.21 1.42.59L11.7 6h7.8A2.5 2.5 0 0 1 22 8.5v9a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 17.5v-11Z" fill="currentColor"/>',
|
||||
'folder' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M6.85929 1.25001C6.88904 1.25001 6.91919 1.25002 6.94976 1.25002L6.98675 1.25001C7.33818 1.24999 7.56433 1.24998 7.78542 1.27065C8.7367 1.35961 9.63905 1.73337 10.3746 2.34313C10.5456 2.48485 10.7055 2.64477 10.954 2.89329L11.5303 3.46969C12.3761 4.3154 12.7012 4.6311 13.0768 4.84005C13.2948 4.96134 13.526 5.05713 13.766 5.12552C14.1793 5.24333 14.6324 5.25002 15.8284 5.25002L16.253 5.25002C17.526 5.25 18.5521 5.24998 19.364 5.35206C20.2054 5.45784 20.9204 5.68358 21.5077 6.21185C21.6061 6.30032 21.6997 6.39394 21.7882 6.49231C22.3165 7.07965 22.5422 7.79459 22.648 8.63601C22.75 9.4479 22.75 10.4741 22.75 11.747V14.0564C22.75 15.8942 22.75 17.3498 22.5969 18.489C22.4393 19.6615 22.1071 20.6104 21.3588 21.3588C20.6104 22.1071 19.6615 22.4393 18.489 22.5969C17.3498 22.75 15.8942 22.75 14.0564 22.75H9.94361C8.10584 22.75 6.65021 22.75 5.51099 22.5969C4.33857 22.4393 3.38962 22.1071 2.64126 21.3588C1.8929 20.6104 1.56078 19.6615 1.40315 18.489C1.24999 17.3498 1.25 15.8942 1.25002 14.0564L1.25002 6.94976C1.25002 6.91919 1.25001 6.88904 1.25001 6.85929C1.2499 6.06338 1.24982 5.55685 1.33237 5.11935C1.6949 3.19788 3.19788 1.6949 5.11935 1.33237C5.55685 1.24982 6.06338 1.2499 6.85929 1.25001ZM6.94976 2.75002C6.03312 2.75002 5.67873 2.75329 5.39746 2.80636C4.08277 3.05441 3.05441 4.08277 2.80636 5.39746C2.75329 5.67873 2.75002 6.03312 2.75002 6.94976V14C2.75002 15.9068 2.75161 17.2615 2.88978 18.2892C3.02504 19.2953 3.27871 19.8749 3.70192 20.2981C4.12513 20.7213 4.70478 20.975 5.71087 21.1103C6.73853 21.2484 8.0932 21.25 10 21.25H14C15.9068 21.25 17.2615 21.2484 18.2892 21.1103C19.2953 20.975 19.8749 20.7213 20.2981 20.2981C20.7213 19.8749 20.975 19.2953 21.1103 18.2892C21.2484 17.2615 21.25 15.9068 21.25 14V11.7979C21.25 10.4621 21.2486 9.5305 21.1597 8.82312C21.0731 8.13448 20.9141 7.76356 20.6729 7.49539C20.6198 7.43637 20.5637 7.3802 20.5046 7.32712C20.2365 7.08592 19.8656 6.92692 19.1769 6.84034C18.4695 6.75141 17.538 6.75002 16.2021 6.75002H15.8284C15.7912 6.75002 15.7545 6.75002 15.7182 6.75003C14.6702 6.75025 13.9944 6.75038 13.3548 6.56806C13.0041 6.46811 12.6661 6.32811 12.3475 6.15083C11.7663 5.82747 11.2885 5.3495 10.5476 4.60833C10.522 4.58265 10.496 4.55666 10.4697 4.53035L9.91943 3.98009C9.63616 3.69682 9.52778 3.58951 9.41731 3.49793C8.91403 3.08073 8.29664 2.825 7.64576 2.76413C7.50289 2.75077 7.35038 2.75002 6.94976 2.75002ZM12.25 10C12.25 9.5858 12.5858 9.25002 13 9.25002H18C18.4142 9.25002 18.75 9.5858 18.75 10C18.75 10.4142 18.4142 10.75 18 10.75H13C12.5858 10.75 12.25 10.4142 12.25 10Z" fill="currentColor"/>',
|
||||
'alert-triangle' => '<path d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V13C12.75 13.4142 12.4142 13.75 12 13.75C11.5858 13.75 11.25 13.4142 11.25 13V8C11.25 7.58579 11.5858 7.25 12 7.25Z" fill="currentColor"/><path d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.2944 4.47643C9.36631 3.11493 10.5018 2.25 12 2.25C13.4981 2.25 14.6336 3.11493 15.7056 4.47643C16.7598 5.81544 17.8769 7.79622 19.3063 10.3305L19.7418 11.1027C20.9234 13.1976 21.8566 14.8523 22.3468 16.1804C22.8478 17.5376 22.9668 18.7699 22.209 19.8569C21.4736 20.9118 20.2466 21.3434 18.6991 21.5471C17.1576 21.75 15.0845 21.75 12.4248 21.75H11.5752C8.91552 21.75 6.84239 21.75 5.30082 21.5471C3.75331 21.3434 2.52637 20.9118 1.79099 19.8569C1.03318 18.7699 1.15218 17.5376 1.65314 16.1804C2.14334 14.8523 3.07658 13.1977 4.25818 11.1027L4.69361 10.3307C6.123 7.79629 7.24019 5.81547 8.2944 4.47643ZM9.47297 5.40432C8.49896 6.64148 7.43704 8.51988 5.96495 11.1299L5.60129 11.7747C4.37507 13.9488 3.50368 15.4986 3.06034 16.6998C2.6227 17.8855 2.68338 18.5141 3.02148 18.9991C3.38202 19.5163 4.05873 19.8706 5.49659 20.0599C6.92858 20.2484 8.9026 20.25 11.6363 20.25H12.3636C15.0974 20.25 17.0714 20.2484 18.5034 20.0599C19.9412 19.8706 20.6179 19.5163 20.9785 18.9991C21.3166 18.5141 21.3773 17.8855 20.9396 16.6998C20.4963 15.4986 19.6249 13.9488 18.3987 11.7747L18.035 11.1299C16.5629 8.51987 15.501 6.64148 14.527 5.40431C13.562 4.17865 12.8126 3.75 12 3.75C11.1874 3.75 10.4379 4.17865 9.47297 5.40432Z" fill="currentColor"/>',
|
||||
'alert-circle' => '<path d="M12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V13C12.75 13.4142 12.4142 13.75 12 13.75C11.5858 13.75 11.25 13.4142 11.25 13V7C11.25 6.58579 11.5858 6.25 12 6.25Z" fill="currentColor"/><path d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12ZM12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75Z" fill="currentColor"/>',
|
||||
'check-circle' => '<path d="M16.0303 10.0303C16.3232 9.73744 16.3232 9.26256 16.0303 8.96967C15.7374 8.67678 15.2626 8.67678 14.9697 8.96967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.0303 10.0303Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C6.06294 1.25 1.25 6.06294 1.25 12C1.25 17.9371 6.06294 22.75 12 22.75C17.9371 22.75 22.75 17.9371 22.75 12C22.75 6.06294 17.9371 1.25 12 1.25ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12Z" fill="currentColor"/>',
|
||||
'info-circle' => '<path d="M12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75Z" fill="currentColor"/><path d="M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12ZM12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75Z" fill="currentColor"/>',
|
||||
'arrow-right' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M13.4697 5.46967C13.7626 5.17678 14.2374 5.17678 14.5303 5.46967L20.5303 11.4697C20.8232 11.7626 20.8232 12.2374 20.5303 12.5303L14.5303 18.5303C14.2374 18.8232 13.7626 18.8232 13.4697 18.5303C13.1768 18.2374 13.1768 17.7626 13.4697 17.4697L18.1893 12.75H4C3.58579 12.75 3.25 12.4142 3.25 12C3.25 11.5858 3.58579 11.25 4 11.25H18.1893L13.4697 6.53033C13.1768 6.23744 13.1768 5.76256 13.4697 5.46967Z" fill="currentColor"/>',
|
||||
'edit' => '<path d="M11 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22H15C20 22 22 20 22 15V13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M16.04 3.02001L8.16 10.9C7.86 11.2 7.56 11.79 7.5 12.22L7.07 15.23C6.91 16.32 7.68 17.08 8.77 16.93L11.78 16.5C12.2 16.44 12.79 16.14 13.1 15.84L20.98 7.96001C22.34 6.60001 22.98 5.02001 20.98 3.02001C18.98 1.02001 17.4 1.66001 16.04 3.02001Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.91 4.1499C15.58 6.5399 17.45 8.4099 19.85 9.0899" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'download' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C12.4142 1.25 12.75 1.58579 12.75 2V12.9726L14.4306 11.0119C14.7001 10.6974 15.1736 10.661 15.4881 10.9306C15.8026 11.2001 15.839 11.6736 15.5694 11.9881L12.5694 15.4881C12.427 15.6543 12.2189 15.75 12 15.75C11.7811 15.75 11.573 15.6543 11.4306 15.4881L8.43056 11.9881C8.16099 11.6736 8.19741 11.2001 8.51191 10.9306C8.8264 10.661 9.29988 10.6974 9.56944 11.0119L11.25 12.9726V2C11.25 1.58579 11.5858 1.25 12 1.25ZM6.99583 8.25196C7.41003 8.24966 7.74768 8.58357 7.74999 8.99778C7.7523 9.41199 7.41838 9.74964 7.00418 9.75194C5.91068 9.75803 5.1356 9.78643 4.54735 9.89448C3.98054 9.99859 3.65246 10.1658 3.40901 10.4092C3.13225 10.686 2.9518 11.0746 2.85315 11.8083C2.75159 12.5637 2.75 13.5648 2.75 15.0002V16.0002C2.75 17.4356 2.75159 18.4367 2.85315 19.1921C2.9518 19.9259 3.13225 20.3144 3.40901 20.5912C3.68577 20.868 4.07435 21.0484 4.80812 21.1471C5.56347 21.2486 6.56458 21.2502 8 21.2502H16C17.4354 21.2502 18.4365 21.2486 19.1919 21.1471C19.9257 21.0484 20.3142 20.868 20.591 20.5912C20.8678 20.3144 21.0482 19.9259 21.1469 19.1921C21.2484 18.4367 21.25 17.4356 21.25 16.0002V15.0002C21.25 13.5648 21.2484 12.5637 21.1469 11.8083C21.0482 11.0746 20.8678 10.686 20.591 10.4092C20.3475 10.1658 20.0195 9.99859 19.4527 9.89448C18.8644 9.78643 18.0893 9.75803 16.9958 9.75194C16.5816 9.74964 16.2477 9.41199 16.25 8.99778C16.2523 8.58357 16.59 8.24966 17.0042 8.25196C18.0857 8.25799 18.9871 8.28387 19.7236 8.41916C20.4816 8.55839 21.1267 8.82364 21.6517 9.34857C22.2536 9.95048 22.5125 10.7084 22.6335 11.6085C22.75 12.4754 22.75 13.5778 22.75 14.9453V16.0551C22.75 17.4227 22.75 18.525 22.6335 19.392C22.5125 20.2921 22.2536 21.0499 21.6517 21.6519C21.0497 22.2538 20.2919 22.5127 19.3918 22.6337C18.5248 22.7503 17.4225 22.7502 16.0549 22.7502H7.94513C6.57754 22.7502 5.47522 22.7503 4.60825 22.6337C3.70814 22.5127 2.95027 22.2538 2.34835 21.6519C1.74643 21.0499 1.48754 20.2921 1.36652 19.392C1.24996 18.525 1.24998 17.4227 1.25 16.0551V14.9453C1.24998 13.5778 1.24996 12.4754 1.36652 11.6085C1.48754 10.7084 1.74643 9.95048 2.34835 9.34857C2.87328 8.82363 3.51835 8.55839 4.27635 8.41916C5.01291 8.28387 5.9143 8.25798 6.99583 8.25196Z" fill="currentColor"/>',
|
||||
'upload' => '<path d="M11.4697 3.46967C11.7626 3.17678 12.2374 3.17678 12.5303 3.46967L16.5303 7.46967C16.8232 7.76256 16.8232 8.23744 16.5303 8.53033C16.2374 8.82322 15.7626 8.82322 15.4697 8.53033L12.75 5.81066V14C12.75 14.4142 12.4142 14.75 12 14.75C11.5858 14.75 11.25 14.4142 11.25 14V5.81066L8.53033 8.53033C8.23744 8.82322 7.76256 8.82322 7.46967 8.53033C7.17678 8.23744 7.17678 7.76256 7.46967 7.46967L11.4697 3.46967Z" fill="currentColor"/><path d="M4 14.25C4.41421 14.25 4.75 14.5858 4.75 15V17C4.75 18.5188 5.98122 19.75 7.5 19.75H16.5C18.0188 19.75 19.25 18.5188 19.25 17V15C19.25 14.5858 19.5858 14.25 20 14.25C20.4142 14.25 20.75 14.5858 20.75 15V17C20.75 19.3472 18.8472 21.25 16.5 21.25H7.5C5.15279 21.25 3.25 19.3472 3.25 17V15C3.25 14.5858 3.58579 14.25 4 14.25Z" fill="currentColor"/>',
|
||||
'x' => '<path d="M18.4697 19.5303C18.7626 19.8232 19.2374 19.8232 19.5303 19.5303C19.8232 19.2374 19.8232 18.7626 19.5303 18.4697L13.0607 12L19.5303 5.53033C19.8232 5.23744 19.8232 4.76256 19.5303 4.46967C19.2374 4.17678 18.7626 4.17678 18.4697 4.46967L12 10.9393L5.53033 4.46967C5.23744 4.17678 4.76256 4.17678 4.46967 4.46967C4.17678 4.76256 4.17678 5.23744 4.46967 5.53033L10.9393 12L4.46967 18.4697C4.17678 18.7626 4.17678 19.2374 4.46967 19.5303C4.76256 19.8232 5.23744 19.8232 5.53033 19.5303L12 13.0607L18.4697 19.5303Z" fill="currentColor"/>',
|
||||
'check' => '<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>',
|
||||
'copy' => '<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke="currentColor" stroke-width="1.5"/>',
|
||||
'chevron-down' => '<polyline points="5 9 12 16 19 9" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline>',
|
||||
'dots-horizontal' => '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
|
||||
'trash' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0924 1.25H8.90788C7.33861 1.24998 6.08032 1.24996 5.10577 1.38767C4.09802 1.53007 3.25979 1.83575 2.64218 2.55292C2.02457 3.27008 1.84661 4.14438 1.85528 5.1621C1.86366 6.1463 2.05033 7.39066 2.28314 8.94256L3.49937 17.0508C3.67587 18.2275 3.81878 19.1804 4.02849 19.9262C4.24683 20.7027 4.56045 21.3453 5.13662 21.8415C5.71279 22.3377 6.39485 22.5525 7.19513 22.6533C7.96377 22.75 8.92732 22.75 10.1173 22.75H13.883C15.073 22.75 16.0365 22.75 16.8052 22.6533C17.6054 22.5525 18.2875 22.3377 18.8637 21.8415C19.4398 21.3453 19.7535 20.7027 19.9718 19.9262C20.1815 19.1805 20.3244 18.2276 20.5009 17.0509L21.7172 8.94253C21.95 7.39065 22.1366 6.14629 22.145 5.1621C22.1537 4.14438 21.9757 3.27008 21.3581 2.55292C20.7405 1.83575 19.9023 1.53007 18.8945 1.38767C17.92 1.24996 16.6617 1.24998 15.0924 1.25ZM3.77879 3.53175C4.05882 3.20658 4.47927 2.9911 5.31565 2.87292C6.17295 2.75177 7.32479 2.75 8.96727 2.75H15.033C16.6755 2.75 17.8273 2.75177 18.6846 2.87292C19.521 2.9911 19.9415 3.20658 20.2215 3.53175C20.5015 3.85692 20.6523 4.30468 20.6451 5.14933C20.6448 5.18248 20.6443 5.21604 20.6435 5.25H20.5005C20.5003 5.25 20.5007 5.25 20.5005 5.25H7.00045C7.00025 5.25 7.00065 5.25 7.00045 5.25H3.35678C3.35603 5.21603 3.35551 5.18248 3.35522 5.14933C3.34803 4.30468 3.49876 3.85692 3.77879 3.53175ZM5.18949 6.75H3.48546C3.53687 7.15852 3.60161 7.61096 3.67631 8.1155L3.75015 8.18934L5.18949 6.75ZM4.05013 10.6106L4.6686 14.7338L6.37599 12.9365L4.05013 10.6106ZM5.15659 17.9593C5.17275 18.0594 5.18872 18.1563 5.20463 18.25H5.39887L5.15659 17.9593ZM6.99527 19.75C6.99879 19.75 7.00232 19.75 7.00584 19.75H13.9972C13.9991 19.75 14.0009 19.75 14.0027 19.75H18.4577C18.299 20.2287 18.1176 20.5044 17.8848 20.7049C17.6171 20.9355 17.261 21.0841 16.6178 21.165C15.9538 21.2486 15.0849 21.25 13.833 21.25H10.1673C8.91538 21.25 8.04651 21.2486 7.38247 21.165C6.73934 21.0841 6.38321 20.9355 6.11546 20.7049C5.88266 20.5044 5.70127 20.2287 5.54256 19.75H6.99527ZM15.7131 18.25H18.1895L16.9018 16.9623L15.7131 18.25ZM19.0007 16.9399C19.0087 16.8869 19.0168 16.8332 19.0249 16.7788L19.404 14.2515L17.92 15.8592L19.0007 16.9399ZM19.856 11.2381L20.2249 8.77879C20.3197 8.14673 20.4033 7.5881 20.4704 7.09045L18.16 9.40079L19.856 11.2381ZM18.6895 6.75H15.7131L17.1418 8.2977L18.6895 6.75ZM12.2532 6.75H8.81081L10.5761 8.51531L12.2532 6.75ZM11.6895 18.25H9.31081L10.5002 17.0607L11.6895 18.25ZM7.40946 11.8486L4.81081 9.25L7.00015 7.06066L9.54266 9.60317L7.40946 11.8486ZM8.47047 12.9097L10.6037 10.6642L12.6895 12.75L10.5002 14.9393L8.47047 12.9097ZM11.5608 16L13.7502 13.8107L15.8403 15.9008L13.7385 18.1777L11.5608 16ZM14.8108 12.75L16.8585 14.7977L18.9795 12.5L17.0985 10.4623L14.8108 12.75ZM13.7502 11.6893L16.0803 9.35921L13.9923 7.09721L11.6371 9.57632L13.7502 11.6893ZM7.437 13.9975L9.43949 16L7.27782 18.1617L5.50363 16.0326L7.437 13.9975Z" fill="currentColor"/>',
|
||||
'external-link' => '<path d="M13 11L21.2 2.80005" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 6.8V2H17.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22H15C20 22 22 20 22 15V13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'server-update' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M9.94358 2.25H14.0564C15.8942 2.24998 17.3498 2.24997 18.489 2.40314C19.6614 2.56076 20.6104 2.89288 21.3588 3.64124C22.1071 4.38961 22.4392 5.33856 22.5969 6.51098C22.75 7.65019 22.75 9.10583 22.75 10.9436V13C22.75 13.4142 22.4142 13.75 22 13.75C21.5858 13.75 21.25 13.4142 21.25 13V12.75H2.75V13C2.75 14.9068 2.75159 16.2615 2.88976 17.2892C3.02502 18.2952 3.27869 18.8749 3.7019 19.2981C4.12511 19.7213 4.70476 19.975 5.71085 20.1102C6.73851 20.2484 8.09318 20.25 10 20.25H13C13.4142 20.25 13.75 20.5858 13.75 21C13.75 21.4142 13.4142 21.75 13 21.75H9.94359C8.10583 21.75 6.65019 21.75 5.51098 21.5969C4.33856 21.4392 3.38961 21.1071 2.64124 20.3588C1.89288 19.6104 1.56076 18.6614 1.40314 17.489C1.24997 16.3498 1.24998 14.8942 1.25 13.0564V10.9436C1.24998 9.10582 1.24997 7.65019 1.40314 6.51098C1.56076 5.33856 1.89288 4.38961 2.64124 3.64124C3.38961 2.89288 4.33856 2.56076 5.51098 2.40314C6.65019 2.24997 8.10582 2.24998 9.94358 2.25ZM2.75 11.25H21.25V11C21.25 9.09318 21.2484 7.73851 21.1102 6.71085C20.975 5.70476 20.7213 5.12511 20.2981 4.7019C19.8749 4.27869 19.2952 4.02502 18.2892 3.88976C17.2615 3.75159 15.9068 3.75 14 3.75H10C8.09318 3.75 6.73851 3.75159 5.71085 3.88976C4.70476 4.02502 4.12511 4.27869 3.7019 4.7019C3.27869 5.12511 3.02502 5.70476 2.88976 6.71085C2.75159 7.73851 2.75 9.09318 2.75 11V11.25ZM6 5.75C6.41421 5.75 6.75 6.08579 6.75 6.5V8.5C6.75 8.91421 6.41421 9.25 6 9.25C5.58579 9.25 5.25 8.91421 5.25 8.5V6.5C5.25 6.08579 5.58579 5.75 6 5.75ZM9 5.75C9.41421 5.75 9.75 6.08579 9.75 6.5V8.5C9.75 8.91421 9.41421 9.25 9 9.25C8.58579 9.25 8.25 8.91421 8.25 8.5V6.5C8.25 6.08579 8.58579 5.75 9 5.75ZM12.75 7.5C12.75 7.08579 13.0858 6.75 13.5 6.75H18C18.4142 6.75 18.75 7.08579 18.75 7.5C18.75 7.91421 18.4142 8.25 18 8.25H13.5C13.0858 8.25 12.75 7.91421 12.75 7.5ZM14.9286 16.7534C15.3176 15.2559 16.8529 14.25 18.5048 14.25C19.782 14.25 20.945 14.8304 21.6108 15.7648C21.8512 16.1021 21.7726 16.5704 21.4352 16.8108C21.0979 17.0512 20.6296 16.9726 20.3892 16.6352C20.0295 16.1304 19.3411 15.75 18.5048 15.75C17.5383 15.75 16.8181 16.2319 16.5056 16.8299C16.592 16.8734 16.6713 16.9343 16.738 17.0123C17.0074 17.327 16.9706 17.8004 16.656 18.0698L16.0718 18.5698C15.7911 18.8101 15.3772 18.8101 15.0964 18.5698L14.5123 18.0698C14.1976 17.8004 14.1609 17.327 14.4302 17.0123C14.5618 16.8586 14.742 16.7712 14.9286 16.7534ZM6 14.75C6.41421 14.75 6.75 15.0858 6.75 15.5V17.5C6.75 17.9142 6.41421 18.25 6 18.25C5.58579 18.25 5.25 17.9142 5.25 17.5V15.5C5.25 15.0858 5.58579 14.75 6 14.75ZM9 14.75C9.41421 14.75 9.75 15.0858 9.75 15.5V17.5C9.75 17.9142 9.41421 18.25 9 18.25C8.58579 18.25 8.25 17.9142 8.25 17.5V15.5C8.25 15.0858 8.58579 14.75 9 14.75ZM20.9282 17.4302C21.2089 17.1899 21.6228 17.1899 21.9036 17.4302L22.4877 17.9302C22.8024 18.1996 22.8391 18.673 22.5698 18.9877C22.4382 19.1414 22.258 19.2288 22.0714 19.2466C21.6824 20.7441 20.1471 21.75 18.4952 21.75C17.218 21.75 16.055 21.1696 15.3892 20.2352C15.1488 19.8979 15.2274 19.4296 15.5648 19.1892C15.9021 18.9488 16.3704 19.0274 16.6108 19.3648C16.9705 19.8696 17.6589 20.25 18.4952 20.25C19.4617 20.25 20.1819 19.7681 20.4944 19.1701C20.408 19.1266 20.3287 19.0657 20.262 18.9877C19.9926 18.673 20.0294 18.1996 20.344 17.9302L20.9282 17.4302Z" fill="currentColor"/>',
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Files', 'route' => 'project.service.files', 'icon' => 'folder', 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Analytics', 'route' => 'project.service.analytics', 'icon' => 'analytics'],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Analytics'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Analytics'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
|
||||
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
@@ -74,9 +75,18 @@
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a @class(['menu-item', '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'], $serviceRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['label'] === 'Files')
|
||||
<span x-show="loading" x-cloak><x-loading compact /></span>
|
||||
<x-beta-badge class="ml-auto shrink-0" />
|
||||
@endif
|
||||
@if ($menuItem['hasWarning'] ?? false)
|
||||
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
|
||||
@endif
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="closePanel()"
|
||||
@click.outside="closePanel()">
|
||||
<button type="button" @click="open ? closePanel() : openPanel()"
|
||||
<button type="button" x-ref="userMenuButton" @click="open ? closePanel() : openPanel()"
|
||||
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
|
||||
@if ($sidebar) :class="collapsed ? 'w-8 px-0' : 'w-full'" @endif
|
||||
@class([
|
||||
@@ -44,7 +44,9 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- In the sidebar the panel is fixed to the button, so the scrolling sidebar does not clip it. --}}
|
||||
<div x-show="open" x-cloak
|
||||
@if ($sidebar) x-anchor.top-start.fixed.offset.4="$refs.userMenuButton" @endif
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
@@ -52,7 +54,6 @@
|
||||
@class([
|
||||
'top-user-menu-panel listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible!',
|
||||
'right-0! left-auto!' => ! $sidebar,
|
||||
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar,
|
||||
'origin-bottom-left' => $sidebar,
|
||||
'origin-top-right' => ! $sidebar,
|
||||
])>
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
{{-- ============ DESKTOP SIDEBAR (below top bar) ============ --}}
|
||||
<div class="app-sidebar hidden lg:fixed lg:top-12 lg:bottom-0 lg:left-0 lg:z-40 lg:flex lg:flex-col min-w-0"
|
||||
:class="[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']">
|
||||
<div class="flex grow min-w-0 flex-col overflow-visible">
|
||||
<div class="flex min-h-0 grow min-w-0 flex-col overflow-visible">
|
||||
<x-navbar deployments-indicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
@php
|
||||
$deploymentLabel = $this->deploymentCount.' '.Str::plural('deployment', $this->deploymentCount);
|
||||
@endphp
|
||||
{{-- Deployment list: opens beside the sidebar on desktop. On mobile the root is not positioned,
|
||||
{{-- Deployment list: opens beside the sidebar on desktop, fixed to its button so the
|
||||
scrolling sidebar does not clip it. On mobile the root is not positioned,
|
||||
so the list anchors to the sticky top bar and spans its width. --}}
|
||||
<div x-show="expanded" x-cloak x-transition.opacity.duration.150ms
|
||||
@if ($variant === 'sidebar') x-anchor.right-end.fixed.offset.20="$refs.deploymentsButton" @endif
|
||||
@class([
|
||||
'surface-popover absolute z-60 overflow-hidden rounded-xl',
|
||||
'bottom-0 left-full ml-5 w-[22rem]' => $variant === 'sidebar',
|
||||
'surface-popover z-60 overflow-hidden rounded-xl',
|
||||
'w-[22rem]' => $variant === 'sidebar',
|
||||
'absolute' => $variant === 'mobile',
|
||||
'inset-x-4 top-full mt-2' => $variant === 'mobile',
|
||||
])>
|
||||
<div class="max-h-96 space-y-1 overflow-y-auto p-2 scrollbar">
|
||||
@@ -71,7 +74,7 @@
|
||||
</div>
|
||||
|
||||
@if ($variant === 'sidebar')
|
||||
<button type="button" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
|
||||
<button type="button" x-ref="deploymentsButton" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
|
||||
aria-label="Active deployments" :aria-expanded="expanded.toString()"
|
||||
class="menu-item w-full text-left text-coollabs dark:text-warning"
|
||||
:class="[collapsed && 'lg:justify-center lg:px-0', expanded && 'menu-item-active']">
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
<div wire:init="loadFiles" x-on:open-file-download.window="window.location.href = $event.detail.url">
|
||||
@if (count($pendingDownloads) > 0)
|
||||
<div wire:poll.3s="checkDownloads" class="hidden"></div>
|
||||
@endif
|
||||
<x-slot:title>
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > Files | Coolify
|
||||
</x-slot>
|
||||
|
||||
@if ($type === 'application')
|
||||
<livewire:project.application.heading :application="$resource" wire:key="application-heading-files" />
|
||||
@elseif ($type === 'service')
|
||||
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Files" />
|
||||
@else
|
||||
<livewire:project.database.heading :database="$resource" />
|
||||
@endif
|
||||
|
||||
{{-- Standard resource settings workspace (matches Logs/Configuration) so the
|
||||
heading spacing and sidebar behave identically to every other page. --}}
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0"
|
||||
x-data="{
|
||||
busy: null,
|
||||
{{-- Show a label for the running action over the listing. The short
|
||||
delay skips the overlay for fast requests. --}}
|
||||
run(label, action) {
|
||||
const timer = setTimeout(() => this.busy = label, 150);
|
||||
return Promise.resolve().then(action).catch(() => {}).finally(() => {
|
||||
clearTimeout(timer);
|
||||
this.busy = null;
|
||||
});
|
||||
},
|
||||
uploadFile(file) {
|
||||
return this.run('Uploading file...', () => new Promise((resolve, reject) => {
|
||||
this.$wire.upload('upload', file, () => this.$wire.uploadFile().then(resolve, reject), reject);
|
||||
}));
|
||||
},
|
||||
}">
|
||||
<div class="files-grid grid min-w-0 gap-8">
|
||||
<div class="min-w-0">
|
||||
@if ($type === 'application')
|
||||
<x-application.configuration-sidebar :application="$resource"
|
||||
current-route="project.application.files" />
|
||||
@elseif ($type === 'service')
|
||||
<x-service.configuration-sidebar :service="$resource"
|
||||
current-route="project.service.files" />
|
||||
@else
|
||||
<x-database.configuration-sidebar :database="$resource"
|
||||
current-route="project.database.files" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<x-application.settings-section title="Files"
|
||||
helper="Browse and manage files inside the running container." :flush="true">
|
||||
|
||||
<x-slot:badges>
|
||||
<x-beta-badge />
|
||||
</x-slot:badges>
|
||||
|
||||
<x-slot:actions>
|
||||
@if ($containerRunning && $filesLoaded)
|
||||
<input type="file" class="hidden" x-ref="uploadInput"
|
||||
@change="if ($event.target.files.length) { uploadFile($event.target.files[0]); $event.target.value = '' }" />
|
||||
<x-forms.button type="button" title="Upload a file to this folder" canGate="canAccessTerminal" :canResource="$resource"
|
||||
wire:target="uploadFile" @click="$refs.uploadInput.click()">
|
||||
Upload
|
||||
</x-forms.button>
|
||||
|
||||
<div x-data="{ creationMenuOpen: false }" @keydown.escape.window="creationMenuOpen = false">
|
||||
<x-forms.button type="button" x-ref="newTrigger"
|
||||
@click="creationMenuOpen = !creationMenuOpen" x-bind:aria-expanded="creationMenuOpen"
|
||||
canGate="canAccessTerminal" :canResource="$resource">
|
||||
New
|
||||
<x-reicon name="chevron-down" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
<template x-teleport="body">
|
||||
<div x-show="creationMenuOpen" x-cloak x-transition.opacity.duration.120ms
|
||||
x-anchor.bottom-end.fixed.offset.4="$refs.newTrigger"
|
||||
@click.outside="creationMenuOpen = false"
|
||||
class="listbox-panel min-w-36! w-36! z-50!">
|
||||
<x-modal-input title="New file">
|
||||
<x-slot:content>
|
||||
<button type="button" class="listbox-option" @click="creationMenuOpen = false">
|
||||
<x-reicon name="file" class="size-3.5 shrink-0" />
|
||||
<span class="flex-1 text-left">New file</span>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<form x-data="{ name: '' }"
|
||||
@submit.prevent="run('Creating file...', () => $wire.createFile(name)); name=''; $dispatch('close-modal')"
|
||||
class="flex flex-col gap-4">
|
||||
<x-forms.input id="newFileName" label="File name" x-model="name"
|
||||
placeholder="config.yml" required />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Create file</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
|
||||
<x-modal-input title="New folder">
|
||||
<x-slot:content>
|
||||
<button type="button" class="listbox-option" @click="creationMenuOpen = false">
|
||||
<x-reicon name="folder" class="size-3.5 shrink-0" />
|
||||
<span class="flex-1 text-left">New folder</span>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<form x-data="{ name: '' }"
|
||||
@submit.prevent="run('Creating folder...', () => $wire.createDirectory(name)); name=''; $dispatch('close-modal')"
|
||||
class="flex flex-col gap-4">
|
||||
<x-forms.input id="newFolderName" label="Folder name" x-model="name"
|
||||
placeholder="plugins" required />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Create folder</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<x-forms.button type="button" @click="run('Loading files...', () => $wire.refresh())" title="Refresh this folder">
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($loadFailed)
|
||||
<x-empty title="Could not load files" description="Check the server connection and try again." />
|
||||
<div class="flex justify-center pb-4">
|
||||
<x-forms.button wire:click="loadFiles">Retry</x-forms.button>
|
||||
</div>
|
||||
@elseif (! $filesLoaded)
|
||||
<div class="flex justify-center py-12">
|
||||
<x-loading text="Loading files..." />
|
||||
</div>
|
||||
@elseif (! $containerRunning)
|
||||
<x-empty title="Container is not running"
|
||||
description="Start the container to browse its files.">
|
||||
<x-slot:icon>
|
||||
<x-reicon name="folder" class="size-8" />
|
||||
</x-slot:icon>
|
||||
</x-empty>
|
||||
@else
|
||||
{{-- Keep the current listing visible while an action runs: blur it
|
||||
and show the action label (from run()) on top of it. --}}
|
||||
<div class="relative">
|
||||
<div x-show="busy" x-cloak class="absolute inset-0 z-10">
|
||||
<div class="flex h-full items-start justify-center">
|
||||
<div class="sticky top-[40vh] mt-12 inline-flex items-center gap-2 rounded-md bg-white px-3 py-1.5 text-[13px] text-neutral-500 shadow-sm ring-1 ring-black/5 dark:bg-coolgray-100 dark:text-fg-dim dark:ring-white/10">
|
||||
<x-loading />
|
||||
<span x-text="busy"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-bind:class="busy && 'pointer-events-none select-none blur-[2px] opacity-60'"
|
||||
class="transition-[filter,opacity] duration-150 motion-reduce:transition-none">
|
||||
{{-- Container picker (services / multi-container) --}}
|
||||
@if (count($availableContainers) > 1)
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-coollabs-hairline px-4 py-2">
|
||||
<span class="text-xs text-coollabs-subtle">Container</span>
|
||||
@foreach ($availableContainers as $name)
|
||||
<button type="button" x-on:click="run('Loading files...', () => $wire.selectContainer(@js($name)))"
|
||||
@class([
|
||||
'button',
|
||||
'bg-coollabs/10 text-coollabs ring-coollabs/25 dark:bg-white/[0.08] dark:text-white dark:ring-white/15' => $name === $container,
|
||||
])>{{ $name }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Breadcrumb. The root crumb renders as "/" and doubles as the
|
||||
first separator, so we only draw a slash between named segments
|
||||
(index >= 2) - avoids the "/ /" duplicate. --}}
|
||||
<div class="flex flex-wrap items-center gap-1 px-4 py-2 text-sm">
|
||||
@foreach ($this->breadcrumbs() as $crumb)
|
||||
@if ($loop->index > 1)
|
||||
<span class="text-coollabs-subtle">/</span>
|
||||
@endif
|
||||
<button type="button" x-on:click="run('Loading files...', () => $wire.goTo(@js($crumb['path'])))"
|
||||
@class([
|
||||
'rounded-md px-1.5 py-0.5 hover:bg-black/5 hover:text-current dark:hover:bg-white/[0.06]',
|
||||
'text-current font-medium' => $loop->last,
|
||||
'text-coollabs-subtle' => ! $loop->last,
|
||||
])>
|
||||
{{ $crumb['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Drag-and-drop file list --}}
|
||||
<div x-data="{ dragging: false }"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="dragging = false; if ($event.dataTransfer.files.length) { uploadFile($event.dataTransfer.files[0]) }"
|
||||
:class="dragging ? 'ring-2 ring-coollabs/40 dark:ring-white/30' : ''"
|
||||
class="transition-[box-shadow] duration-150 ease-out motion-reduce:transition-none">
|
||||
@if (count($entries) === 0)
|
||||
<x-empty size="sm" title="This folder is empty"
|
||||
description="Upload a file to get started.">
|
||||
<x-slot:icon>
|
||||
<x-reicon name="folder" class="size-8" />
|
||||
</x-slot:icon>
|
||||
</x-empty>
|
||||
@else
|
||||
<div class="data-table w-full">
|
||||
<div class="data-table-header file-table-grid">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
<span>Perms</span>
|
||||
<span>Owner</span>
|
||||
<span>Group</span>
|
||||
<span class="text-right">Size</span>
|
||||
<span>Modified</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
@foreach ($entries as $entry)
|
||||
<div wire:key="entry-{{ $entry['name'] }}" class="data-table-row file-table-grid">
|
||||
<div class="flex items-center">
|
||||
<x-reicon :name="$entry['type'] === 'dir' ? 'folder-filled' : 'file'"
|
||||
class="size-4 {{ $entry['type'] === 'dir' ? 'text-coollabs dark:text-neutral-200' : 'text-coollabs-subtle opacity-70' }}" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@if ($entry['type'] === 'dir')
|
||||
<button type="button" x-on:click="run('Loading files...', () => $wire.open(@js($entry['name'])))"
|
||||
class="block w-full cursor-pointer truncate text-left font-medium hover:text-coollabs hover:underline dark:hover:text-white">{{ $entry['name'] }}</button>
|
||||
@else
|
||||
<span class="block truncate">{{ $entry['name'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
{{-- Permissions (click to chmod) --}}
|
||||
<div class="min-w-0">
|
||||
<x-modal-input title="Change permissions">
|
||||
<x-slot:content>
|
||||
<button type="button" title="Edit permissions"
|
||||
class="cursor-pointer rounded-md px-1.5 py-0.5 font-mono text-xs text-coollabs-subtle hover:bg-black/5 hover:text-current dark:hover:bg-white/[0.06]">
|
||||
{{ $entry['perms'] ?: '-' }}
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<form x-data="{ mode: @js($entry['perms']) }"
|
||||
@submit.prevent="run('Updating permissions...', () => $wire.chmodEntry(@js($entry['name']), mode)); $dispatch('close-modal')"
|
||||
class="flex flex-col gap-4">
|
||||
<p class="text-sm text-coollabs-subtle">
|
||||
Set the octal permission mode for
|
||||
<span class="font-medium">{{ $entry['name'] }}</span>.
|
||||
</p>
|
||||
<x-forms.input id="chmodMode-{{ $loop->index }}" label="Octal mode"
|
||||
x-model="mode" placeholder="644" required />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Apply</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-coollabs-subtle">
|
||||
{{ $entry['owner'] ?: '-' }}
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-coollabs-subtle">
|
||||
{{ $entry['group'] ?: '-' }}
|
||||
</div>
|
||||
<div class="text-right text-coollabs-subtle tabular-nums">
|
||||
{{ $entry['type'] === 'dir' ? '-' : formatBytes($entry['size']) }}
|
||||
</div>
|
||||
<div class="text-coollabs-subtle tabular-nums">
|
||||
{{ $entry['mtime'] ? \Illuminate\Support\Carbon::createFromTimestamp($entry['mtime'])->toDateTimeString() : '-' }}
|
||||
</div>
|
||||
{{-- Actions kebab. The entry name lives in this
|
||||
row's x-data as a JS string so the menu can
|
||||
call $wire.* with it directly - passing @js()
|
||||
through a component attribute double-encodes
|
||||
the quotes and breaks the handler. --}}
|
||||
<div class="flex items-center justify-end"
|
||||
x-data="{ open: false, name: @js($entry['name']), isDir: @js($entry['type'] === 'dir') }"
|
||||
@keydown.escape.window="open = false">
|
||||
<div class="relative">
|
||||
<button type="button" title="Actions" aria-label="Actions" x-ref="trigger"
|
||||
x-on:click="open = !open" x-bind:aria-expanded="open"
|
||||
x-bind:class="open && 'bg-black/5 text-current dark:bg-white/[0.08]'"
|
||||
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-coollabs-subtle hover:bg-black/5 hover:text-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs/40 dark:hover:bg-white/[0.06] dark:focus-visible:ring-white/30">
|
||||
<x-reicon name="dots-horizontal" class="size-4" />
|
||||
</button>
|
||||
<template x-teleport="body">
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
x-anchor.bottom-end.fixed.offset.4="$refs.trigger"
|
||||
@click.outside="open = false"
|
||||
class="listbox-panel min-w-52! w-52! z-50!">
|
||||
@if ($entry['type'] !== 'dir')
|
||||
<button type="button" class="listbox-option"
|
||||
x-on:click="open = false; run('Opening file...', () => $wire.openEditor(name))">
|
||||
<x-reicon name="file-content" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 text-left">Edit</span>
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="listbox-option"
|
||||
x-on:click="open = false; run(isDir ? 'Preparing folder download...' : 'Preparing file download...', () => $wire.download(name))">
|
||||
<x-reicon name="download" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 text-left">Download</span>
|
||||
</button>
|
||||
|
||||
{{-- Rename --}}
|
||||
<x-modal-input title="Rename">
|
||||
<x-slot:content>
|
||||
<button type="button" class="listbox-option" x-on:click="open = false">
|
||||
<x-reicon name="edit" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 text-left">Rename</span>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<form x-data="{ newName: @js($entry['name']) }"
|
||||
@submit.prevent="run('Renaming...', () => $wire.renameEntry(name, newName)); $dispatch('close-modal')"
|
||||
class="flex flex-col gap-4">
|
||||
<x-forms.input id="renameName-{{ $loop->index }}"
|
||||
label="New name" x-model="newName" required />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Rename</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
|
||||
{{-- Delete --}}
|
||||
<x-modal-input title="Delete this item?">
|
||||
<x-slot:content>
|
||||
<button type="button" class="listbox-option" x-on:click="open = false">
|
||||
<x-reicon name="trash" class="size-3.5 shrink-0 text-red-600 dark:text-red-500" />
|
||||
<span class="min-w-0 flex-1 text-left text-red-600 dark:text-red-500">Delete</span>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-coollabs-subtle">
|
||||
This permanently deletes
|
||||
<span class="font-medium">{{ $entry['name'] }}</span>
|
||||
inside the container. This cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button isError type="button" canGate="canAccessTerminal" :canResource="$resource"
|
||||
x-on:click="run(isDir ? 'Deleting folder...' : 'Deleting file...', () => $wire.deleteEntry(name)); $dispatch('close-modal')">Delete</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Editor modal (opens when a text file is loaded) --}}
|
||||
<x-modal-input title="Edit file" :wireOpen="'editorOpen'" :content="true" wireIgnore="false" isLarge>
|
||||
<x-slot:content>
|
||||
<span class="hidden"></span>
|
||||
</x-slot:content>
|
||||
<form @submit.prevent="run('Saving file...', () => $wire.saveEditor())" class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="truncate font-mono text-xs text-coollabs-subtle">{{ $editingPath }}</p>
|
||||
@if ($editorLanguage)
|
||||
<span class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 font-mono text-[11px] text-coollabs dark:bg-white/[0.08] dark:text-neutral-200">{{ $editorLanguage }}</span>
|
||||
@endif
|
||||
</div>
|
||||
{{-- Monaco editor, kept mounted across opens (wire:ignore) and
|
||||
preloaded on page init so the first Edit click is instant.
|
||||
openEditor dispatches load-file-editor with the content and
|
||||
Monaco language id; we setValue + setModelLanguage without
|
||||
recreating, so highlighting applies and there is no flash.
|
||||
Inline Alpine (like x-forms.monaco-editor) means this works
|
||||
without a JS rebuild. --}}
|
||||
<div wire:ignore class="min-w-0" x-init="loadMonaco()"
|
||||
x-on:load-file-editor.window="load($event.detail)"
|
||||
x-data="{
|
||||
vs: '/js/monaco-editor-0.52.2/min/vs',
|
||||
base: '/js/monaco-editor-0.52.2/min',
|
||||
isDark() {
|
||||
return document.documentElement.classList.contains('dark') || localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
},
|
||||
loadMonaco() {
|
||||
if (window.__fileEditorMonaco) return window.__fileEditorMonaco;
|
||||
const vs = this.vs, base = this.base;
|
||||
window.__fileEditorMonaco = new Promise((resolve, reject) => {
|
||||
const boot = () => {
|
||||
window.require.config({ paths: { vs } });
|
||||
const proxy = URL.createObjectURL(new Blob([`self.MonacoEnvironment={baseUrl:'${window.location.origin}${base}'};importScripts('${window.location.origin}${vs}/base/worker/workerMain.js');`], { type: 'text/javascript' }));
|
||||
window.MonacoEnvironment = { getWorkerUrl: () => proxy };
|
||||
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||
};
|
||||
if (typeof window.require !== 'undefined' && window.require.config) { boot(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = `${vs}/loader.js`; s.onload = boot; s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return window.__fileEditorMonaco;
|
||||
},
|
||||
ensureTheme(monaco) {
|
||||
if (window.__fileEditorThemeDefined) return;
|
||||
monaco.editor.defineTheme('coolify-dark', { base: 'vs-dark', inherit: true, rules: [], colors: { 'editor.background': '#0b0b0c', 'editorGutter.background': '#0b0b0c', 'minimap.background': '#0b0b0c' } });
|
||||
window.__fileEditorThemeDefined = true;
|
||||
},
|
||||
async load(detail) {
|
||||
const monaco = await this.loadMonaco();
|
||||
this.ensureTheme(monaco);
|
||||
const el = this.$refs.editor;
|
||||
const wire = this.$wire;
|
||||
const content = (detail && detail.content) || '';
|
||||
const language = (detail && detail.language) || 'plaintext';
|
||||
// Keep the Monaco instance on the DOM node, NOT in
|
||||
// Alpine reactive state - proxying its huge object
|
||||
// graph would hang and crash the tab.
|
||||
if (!el._monacoEditor) {
|
||||
const editor = monaco.editor.create(el, { value: content, language, theme: this.isDark() ? 'coolify-dark' : 'vs', automaticLayout: true, minimap: { enabled: false }, fontSize: 14, lineNumbersMinChars: 3, scrollBeyondLastLine: false, renderLineHighlight: 'all', padding: { top: 12, bottom: 12 }, scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8, useShadows: false } });
|
||||
el._monacoEditor = editor;
|
||||
editor.onDidChangeModelContent(() => { wire.set('editorContent', editor.getValue(), false); });
|
||||
new MutationObserver(() => monaco.editor.setTheme(this.isDark() ? 'coolify-dark' : 'vs')).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
} else {
|
||||
const model = el._monacoEditor.getModel();
|
||||
model.setValue(content);
|
||||
monaco.editor.setModelLanguage(model, language);
|
||||
}
|
||||
const editor = el._monacoEditor;
|
||||
this.$nextTick(() => { editor.layout(); setTimeout(() => editor.focus(), 40); });
|
||||
}
|
||||
}">
|
||||
<div x-ref="editor" class="file-editor-monaco"></div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<x-forms.button type="button" wire:click="closeEditor"
|
||||
@click="$dispatch('close-modal')">Cancel</x-forms.button>
|
||||
<x-forms.button type="submit" isHighlighted
|
||||
@click="$dispatch('close-modal')">Save changes</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -171,6 +171,22 @@
|
||||
</x-callout>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="file-download-storage-section" title="File downloads"
|
||||
helper="Choose how files from the container file browser are downloaded. Streaming sends each file through this Coolify instance, up to 1 GB. With S3, the server uploads the file to the bucket and the browser downloads it from S3, up to 5 GB. S3 download links expire after 15 minutes, and the files are deleted after 6 hours.">
|
||||
<div class="flex max-w-md flex-col gap-4">
|
||||
<x-forms.listbox id="file_download_storage" label="Download method" onChange="instantSave"
|
||||
:options="[
|
||||
['value' => 'stream', 'label' => 'Stream through Coolify'],
|
||||
...array_values(array_filter($avatar_storage_options, fn ($option) => $option['value'] !== 'local')),
|
||||
]" />
|
||||
</div>
|
||||
<x-callout type="info" title="Bucket auto-delete rule" class="mt-4">
|
||||
When you select an S3 storage, Coolify adds a lifecycle rule that deletes objects under
|
||||
<span class="font-mono">coolify-file-downloads/</span> after 1 day, as a safety net. The selected
|
||||
storage must be reachable from your servers, and the S3 provider must support lifecycle rules.
|
||||
</x-callout>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
</x-settings.layout>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
use App\Data\FileEntry;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('ssh-keys');
|
||||
// Disable SSH multiplexing so each instant_remote_process is exactly one
|
||||
// faked Process call (mux setup would run extra calls and desync sequences).
|
||||
config(['constants.ssh.mux_enabled' => 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);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DeleteFileDownloadJob;
|
||||
use App\Jobs\PrepareFileDownloadJob;
|
||||
use App\Livewire\Settings\Advanced;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use App\Services\FileDownloadStorageService;
|
||||
use Aws\CommandInterface;
|
||||
use Aws\MockHandler;
|
||||
use Aws\Result;
|
||||
use Aws\S3\Exception\S3Exception;
|
||||
use Aws\S3\S3Client;
|
||||
use Illuminate\Filesystem\AwsS3V3Adapter;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 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"');
|
||||
@@ -0,0 +1,770 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\FileBrowserDownloadController;
|
||||
use App\Jobs\PrepareFileDownloadJob;
|
||||
use App\Livewire\Project\Shared\FileBrowser;
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use App\Services\FileDownloadStorageService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Process\Factory;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
Reference in New Issue
Block a user