diff --git a/app/Actions/Database/ManageBackupFile.php b/app/Actions/Database/ManageBackupFile.php index a88212a..7988cbf 100644 --- a/app/Actions/Database/ManageBackupFile.php +++ b/app/Actions/Database/ManageBackupFile.php @@ -15,16 +15,12 @@ class ManageBackupFile */ public function download(BackupFile $file): StreamedResponse { - $localFilename = "backup_{$file->id}_{$file->name}.zip"; + $file->backup->server->ssh()->download( + Storage::disk('tmp')->path(basename($file->path())), + $file->path() + ); - if (! Storage::disk('backups')->exists($localFilename)) { - $file->backup->server->ssh()->download( - Storage::disk('backups')->path($localFilename), - $file->path() - ); - } - - return Storage::disk('backups')->download($localFilename, $file->name.'.zip'); + return Storage::disk('tmp')->download(basename($file->path())); } public function delete(BackupFile $file): void diff --git a/app/Actions/FileManager/FetchFiles.php b/app/Actions/FileManager/FetchFiles.php new file mode 100644 index 0000000..77aeca0 --- /dev/null +++ b/app/Actions/FileManager/FetchFiles.php @@ -0,0 +1,39 @@ +os()->ls($input['path'], $input['user']) + ); + } + + public static function rules(Server $server): array + { + return [ + 'path' => [ + 'required', + ], + 'user' => [ + 'required', + Rule::in($server->getSshUsers()), + ], + ]; + } +} diff --git a/app/Helpers/SSH.php b/app/Helpers/SSH.php index 275e4cb..40e6bdc 100755 --- a/app/Helpers/SSH.php +++ b/app/Helpers/SSH.php @@ -94,11 +94,7 @@ public function connect(bool $sftp = false): void */ public function exec(string $command, string $log = '', ?int $siteId = null, ?bool $stream = false, ?callable $streamCallback = null): string { - if (! $log) { - $log = 'run-command'; - } - - if (! $this->log) { + if (! $this->log && $log) { $this->log = ServerLog::make($this->server, $log); if ($siteId) { $this->log->forSite($siteId); @@ -122,7 +118,7 @@ public function exec(string $command, string $log = '', ?int $siteId = null, ?bo $this->connection->setTimeout(0); if ($stream) { $this->connection->exec($command, function ($output) use ($streamCallback) { - $this->log->write($output); + $this->log?->write($output); return $streamCallback($output); }); @@ -131,7 +127,7 @@ public function exec(string $command, string $log = '', ?int $siteId = null, ?bo } else { $output = ''; $this->connection->exec($command, function ($out) use (&$output) { - $this->log->write($out); + $this->log?->write($out); $output .= $out; }); diff --git a/app/Models/File.php b/app/Models/File.php new file mode 100644 index 0000000..7948d4d --- /dev/null +++ b/app/Models/File.php @@ -0,0 +1,147 @@ + 'integer', + 'server_id' => 'integer', + 'size' => 'integer', + 'links' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + protected static function boot(): void + { + parent::boot(); + + static::deleting(function (File $file) { + if ($file->name === '.' || $file->name === '..') { + return false; + } + + $file->server->os()->deleteFile($file->getFilePath(), $file->server_user); + + return true; + }); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function path(User $user, Server $server, string $serverUser): string + { + $file = self::query() + ->where('user_id', $user->id) + ->where('server_id', $server->id) + ->where('server_user', $serverUser) + ->first(); + + if ($file) { + return $file->path; + } + + return home_path($serverUser); + } + + public static function parse(User $user, Server $server, string $path, string $serverUser, string $listOutput): void + { + self::query() + ->where('user_id', $user->id) + ->where('server_id', $server->id) + ->delete(); + + // Split output by line + $lines = explode("\n", trim($listOutput)); + + // Skip the first two lines (total count and . & .. directories) + array_shift($lines); + + foreach ($lines as $line) { + if (preg_match('/^([drwx\-]+)\s+(\d+)\s+(\w+)\s+(\w+)\s+(\d+)\s+([\w\s:\-]+)\s+(.+)$/', $line, $matches)) { + $type = match ($matches[1][0]) { + '-' => 'file', + 'd' => 'directory', + default => 'unknown', + }; + if ($type === 'unknown') { + continue; + } + if ($matches[7] === '.') { + continue; + } + self::create([ + 'user_id' => $user->id, + 'server_id' => $server->id, + 'server_user' => $serverUser, + 'path' => $path, + 'type' => $type, + 'name' => $matches[7], + 'size' => (int) $matches[5], + 'links' => (int) $matches[2], + 'owner' => $matches[3], + 'group' => $matches[4], + 'date' => $matches[6], + 'permissions' => $matches[1], + ]); + } + } + } + + public function getFilePath(): string + { + return $this->path.'/'.$this->name; + } + + public function isExtractable(): bool + { + $extension = pathinfo($this->name, PATHINFO_EXTENSION); + + return in_array($extension, ['zip', 'tar', 'tar.gz', 'bz2', 'tar.bz2']); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 2248760..a0cec5e 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -5,6 +5,7 @@ use App\Actions\Server\CheckConnection; use App\Enums\ServerStatus; use App\Enums\ServiceStatus; +use App\Exceptions\SSHError; use App\Facades\SSH; use App\ServerTypes\ServerType; use App\SSH\Cron\Cron; @@ -22,6 +23,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Throwable; /** * @property int $project_id @@ -146,7 +148,7 @@ public static function boot(): void } $server->provider()->delete(); DB::commit(); - } catch (\Throwable $e) { + } catch (Throwable $e) { DB::rollBack(); throw $e; } @@ -465,6 +467,9 @@ public function cron(): Cron return new Cron($this); } + /** + * @throws SSHError + */ public function checkForUpdates(): void { $this->updates = $this->os()->availableUpdates(); @@ -480,4 +485,15 @@ public function getAvailableUpdatesAttribute(?int $value): int return $value; } + + /** + * @throws Throwable + */ + public function download(string $path, string $disk = 'tmp'): void + { + $this->ssh()->download( + Storage::disk($disk)->path(basename($path)), + $path + ); + } } diff --git a/app/SSH/OS/OS.php b/app/SSH/OS/OS.php index 15079ff..6e95b8d 100644 --- a/app/SSH/OS/OS.php +++ b/app/SSH/OS/OS.php @@ -263,10 +263,14 @@ public function download(string $url, string $path): string /** * @throws SSHError */ - public function unzip(string $path): string + public function extract(string $path, ?string $destination = null, ?string $user = null): void { - return $this->server->ssh()->exec( - 'unzip '.$path + $this->server->ssh($user)->exec( + view('ssh.os.extract', [ + 'path' => $path, + 'destination' => $destination, + ]), + 'extract' ); } @@ -304,9 +308,9 @@ public function resourceInfo(): array /** * @throws SSHError */ - public function deleteFile(string $path): void + public function deleteFile(string $path, ?string $user = null): void { - $this->server->ssh()->exec( + $this->server->ssh($user)->exec( view('ssh.os.delete-file', [ 'path' => $path, ]), @@ -314,6 +318,33 @@ public function deleteFile(string $path): void ); } + /** + * @throws SSHError + */ + public function ls(string $path, ?string $user = null): string + { + return $this->server->ssh($user)->exec('ls -la '.$path); + } + + /** + * @throws SSHError + */ + public function write(string $path, string $content, ?string $user = null): void + { + $this->server->ssh($user)->write( + $path, + $content + ); + } + + /** + * @throws SSHError + */ + public function mkdir(string $path, ?string $user = null): string + { + return $this->server->ssh($user)->exec('mkdir -p '.$path); + } + private function deleteTempFile(string $name): void { if (Storage::disk('local')->exists($name)) { diff --git a/app/SSH/Services/Webserver/Nginx.php b/app/SSH/Services/Webserver/Nginx.php index 73a423d..3c477de 100755 --- a/app/SSH/Services/Webserver/Nginx.php +++ b/app/SSH/Services/Webserver/Nginx.php @@ -199,11 +199,13 @@ public function setupSSL(Ssl $ssl): void */ public function removeSSL(Ssl $ssl): void { - $this->service->server->ssh()->exec( - 'sudo rm -rf '.dirname($ssl->certificate_path).'*', - 'remove-ssl', - $ssl->site_id - ); + if ($ssl->certificate_path) { + $this->service->server->ssh()->exec( + 'sudo rm -rf '.dirname($ssl->certificate_path), + 'remove-ssl', + $ssl->site_id + ); + } $this->updateVHost($ssl->site); } diff --git a/app/Support/helpers.php b/app/Support/helpers.php index d59ee9b..e7b3cfd 100755 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -178,3 +178,31 @@ function get_from_route(string $modelName, string $routeKey): mixed return null; } + +function absolute_path(string $path): string +{ + $parts = explode('/', $path); + $absoluteParts = []; + + foreach ($parts as $part) { + if ($part === '' || $part === '.') { + continue; // Skip empty and current directory parts + } + if ($part === '..') { + array_pop($absoluteParts); // Move up one directory + } else { + $absoluteParts[] = $part; // Add valid directory parts + } + } + + return '/'.implode('/', $absoluteParts); +} + +function home_path(string $user): string +{ + if ($user === 'root') { + return '/root'; + } + + return '/home/'.$user; +} diff --git a/app/Web/Pages/Servers/FileManager/Index.php b/app/Web/Pages/Servers/FileManager/Index.php new file mode 100644 index 0000000..a5f9412 --- /dev/null +++ b/app/Web/Pages/Servers/FileManager/Index.php @@ -0,0 +1,26 @@ +authorize('update', $this->server); + } + + public function getWidgets(): array + { + return [ + [Widgets\FilesList::class, ['server' => $this->server]], + ]; + } +} diff --git a/app/Web/Pages/Servers/FileManager/Widgets/FilesList.php b/app/Web/Pages/Servers/FileManager/Widgets/FilesList.php new file mode 100644 index 0000000..244900d --- /dev/null +++ b/app/Web/Pages/Servers/FileManager/Widgets/FilesList.php @@ -0,0 +1,371 @@ +serverUser = $this->server->ssh_user; + $this->path = home_path($this->serverUser); + if (request()->has('path') && request()->has('user')) { + $this->path = request('path'); + $this->serverUser = request('user'); + } + $this->refresh(); + } + + protected function getTableHeaderActions(): array + { + return [ + $this->homeAction(), + $this->userAction(), + ActionGroup::make([ + $this->refreshAction(), + $this->newFileAction(), + $this->newDirectoryAction(), + $this->uploadAction(), + ]) + ->tooltip('Toolbar') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray') + ->size(ActionSize::Large) + ->iconPosition(IconPosition::After) + ->dropdownPlacement('bottom-end'), + ]; + } + + protected function getTableQuery(): Builder + { + return File::query() + ->where('user_id', auth()->id()) + ->where('server_id', $this->server->id); + } + + public function table(Table $table): Table + { + return $table + ->query($this->getTableQuery()) + ->headerActions($this->getTableHeaderActions()) + ->heading(str($this->path)->substr(-50)->start(str($this->path)->length() > 50 ? '...' : '')) + ->columns([ + IconColumn::make('type') + ->sortable() + ->icon(fn (File $file) => $this->getIcon($file)), + TextColumn::make('name') + ->sortable(), + TextColumn::make('size') + ->sortable(), + TextColumn::make('owner') + ->sortable(), + TextColumn::make('group') + ->sortable(), + TextColumn::make('date') + ->sortable(), + TextColumn::make('permissions') + ->sortable(), + ]) + ->recordUrl(function (File $file) { + if ($file->type === 'directory') { + return Index::getUrl([ + 'server' => $this->server->id, + 'user' => $file->server_user, + 'path' => absolute_path($file->path.'/'.$file->name), + ]); + } + + return ''; + }) + ->defaultSort('type') + ->actions([ + $this->extractAction(), + $this->downloadAction(), + $this->editAction(), + $this->deleteAction(), + ]) + ->checkIfRecordIsSelectableUsing( + fn (File $file): bool => $file->name !== '..', + ) + ->bulkActions([ + DeleteBulkAction::make() + ->requiresConfirmation(), + ]); + } + + public function changeUser(string $user): void + { + $this->redirect( + Index::getUrl([ + 'server' => $this->server->id, + 'user' => $user, + 'path' => home_path($user), + ]), + true + ); + } + + public function refresh(): void + { + try { + app(FetchFiles::class)->fetch( + auth()->user(), + $this->server, + [ + 'user' => $this->serverUser, + 'path' => $this->path, + ] + ); + } catch (SSHError) { + abort(404); + } + $this->dispatch('$refresh'); + } + + protected function getIcon(File $file): string + { + if ($file->type === 'directory') { + return 'heroicon-o-folder'; + } + + if (str($file->name)->endsWith('.blade.php')) { + return 'laravel'; + } + + if (str($file->name)->endsWith('.php')) { + return 'php'; + } + + return 'heroicon-o-document-text'; + } + + protected function homeAction(): Action + { + return Action::make('home') + ->label('Home') + ->size(ActionSize::Small) + ->icon('heroicon-o-home') + ->action(function () { + $this->path = home_path($this->serverUser); + $this->refresh(); + }); + } + + protected function userAction(): ActionGroup + { + $users = []; + foreach ($this->server->getSshUsers() as $user) { + $users[] = Action::make('user-'.$user) + ->action(fn () => $this->changeUser($user)) + ->label($user); + } + + return ActionGroup::make($users) + ->tooltip('Change user') + ->label($this->serverUser) + ->button() + ->size(ActionSize::Small) + ->color('gray') + ->icon('heroicon-o-chevron-up-down') + ->iconPosition(IconPosition::After) + ->dropdownPlacement('bottom-end'); + } + + protected function refreshAction(): Action + { + return Action::make('refresh') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->action(fn () => $this->refresh()); + } + + protected function newFileAction(): Action + { + return Action::make('new-file') + ->label('New File') + ->icon('heroicon-o-document-text') + ->action(function (array $data) { + run_action($this, function () use ($data) { + $this->server->os()->write( + $this->path.'/'.$data['name'], + str_replace("\r\n", "\n", $data['content']), + $this->serverUser + ); + $this->refresh(); + }); + }) + ->form(function () { + return [ + TextInput::make('name') + ->placeholder('file-name.txt'), + CodeEditorField::make('content'), + ]; + }) + ->modalSubmitActionLabel('Create') + ->modalHeading('New File') + ->modalWidth('4xl'); + } + + protected function newDirectoryAction(): Action + { + return Action::make('new-directory') + ->label('New Directory') + ->icon('heroicon-o-folder') + ->action(function (array $data) { + run_action($this, function () use ($data) { + $this->server->os()->mkdir( + $this->path.'/'.$data['name'], + $this->serverUser + ); + $this->refresh(); + }); + }) + ->form(function () { + return [ + TextInput::make('name') + ->placeholder('directory name'), + ]; + }) + ->modalSubmitActionLabel('Create') + ->modalHeading('New Directory') + ->modalWidth('lg'); + } + + protected function uploadAction(): Action + { + return Action::make('upload') + ->label('Upload File') + ->icon('heroicon-o-arrow-up-on-square') + ->action(function (array $data) { + // + }) + ->after(function (array $data) { + run_action($this, function () use ($data) { + foreach ($data['file'] as $file) { + $this->server->ssh($this->serverUser)->upload( + Storage::disk('tmp')->path($file), + $this->path.'/'.$file, + ); + } + $this->refresh(); + }); + }) + ->form(function () { + return [ + FileUpload::make('file') + ->disk('tmp') + ->multiple() + ->preserveFilenames(), + ]; + }) + ->modalSubmitActionLabel('Upload to Server') + ->modalHeading('Upload File') + ->modalWidth('xl'); + } + + protected function extractAction(): Action + { + return Action::make('extract') + ->tooltip('Extract') + ->icon('heroicon-o-archive-box') + ->hiddenLabel() + ->visible(fn (File $file) => $file->isExtractable()) + ->action(function (File $file) { + $file->server->os()->extract($file->getFilePath(), $file->path, $file->server_user); + $this->refresh(); + }); + } + + protected function downloadAction(): Action + { + return Action::make('download') + ->tooltip('Download') + ->icon('heroicon-o-arrow-down-tray') + ->hiddenLabel() + ->visible(fn (File $file) => $file->type === 'file') + ->action(function (File $file) { + $file->server->ssh($file->server_user)->download( + Storage::disk('tmp')->path($file->name), + $file->getFilePath() + ); + + return Storage::disk('tmp')->download($file->name); + }); + } + + protected function editAction(): Action + { + return Action::make('edit') + ->tooltip('Edit') + ->icon('heroicon-o-pencil') + ->hiddenLabel() + ->visible(fn (File $file) => $file->type === 'file') + ->action(function (File $file, array $data) { + $file->server->os()->write( + $file->getFilePath(), + str_replace("\r\n", "\n", $data['content']), + $file->server_user + ); + $this->refresh(); + }) + ->form(function (File $file) { + return [ + CodeEditorField::make('content') + ->formatStateUsing(function () use ($file) { + $file->server->ssh($file->server_user)->download( + Storage::disk('tmp')->path($file->name), + $file->getFilePath() + ); + + return Storage::disk('tmp')->get(basename($file->getFilePath())); + }), + ]; + }) + ->modalSubmitActionLabel('Save') + ->modalHeading('Edit') + ->modalWidth('4xl'); + } + + protected function deleteAction(): Action + { + return Action::make('delete') + ->tooltip('Delete') + ->icon('heroicon-o-trash') + ->color('danger') + ->hiddenLabel() + ->requiresConfirmation() + ->visible(fn (File $file) => $file->name !== '..') + ->action(function (File $file) { + run_action($this, function () use ($file) { + $file->delete(); + }); + }); + } +} diff --git a/app/Web/Pages/Servers/Page.php b/app/Web/Pages/Servers/Page.php index c082cda..9b38077 100644 --- a/app/Web/Pages/Servers/Page.php +++ b/app/Web/Pages/Servers/Page.php @@ -15,6 +15,7 @@ use App\Web\Pages\Servers\Console\Index as ConsoleIndex; use App\Web\Pages\Servers\CronJobs\Index as CronJobsIndex; use App\Web\Pages\Servers\Databases\Index as DatabasesIndex; +use App\Web\Pages\Servers\FileManager\Index as FileManagerIndex; use App\Web\Pages\Servers\Firewall\Index as FirewallIndex; use App\Web\Pages\Servers\Logs\Index as LogsIndex; use App\Web\Pages\Servers\Metrics\Index as MetricsIndex; @@ -59,6 +60,13 @@ public function getSubNavigation(): array ->url(DatabasesIndex::getUrl(parameters: ['server' => $this->server])); } + if (auth()->user()->can('update', $this->server)) { + $items[] = NavigationItem::make(FileManagerIndex::getNavigationLabel()) + ->icon('heroicon-o-folder') + ->isActiveWhen(fn () => request()->routeIs(FileManagerIndex::getRouteName().'*')) + ->url(FileManagerIndex::getUrl(parameters: ['server' => $this->server])); + } + if (auth()->user()->can('viewAny', [Service::class, $this->server])) { $items[] = NavigationItem::make(PHPIndex::getNavigationLabel()) ->icon('icon-php-alt') diff --git a/config/filesystems.php b/config/filesystems.php index 59b7c61..8e3de33 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -62,7 +62,7 @@ 'root' => storage_path('app/key-pairs'), ], - 'backups' => [ + 'tmp' => [ 'driver' => 'local', 'root' => sys_get_temp_dir(), ], diff --git a/database/factories/FileFactory.php b/database/factories/FileFactory.php new file mode 100644 index 0000000..f53172c --- /dev/null +++ b/database/factories/FileFactory.php @@ -0,0 +1,32 @@ + $this->faker->randomNumber(), // + 'server_id' => $this->faker->randomNumber(), + 'server_user' => $this->faker->word(), + 'path' => $this->faker->word(), + 'type' => 'file', + 'name' => $this->faker->name(), + 'size' => $this->faker->randomNumber(), + 'links' => $this->faker->randomNumber(), + 'owner' => $this->faker->word(), + 'group' => $this->faker->word(), + 'date' => $this->faker->word(), + 'permissions' => $this->faker->word(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/database/migrations/2025_02_02_124012_create_files_table.php b/database/migrations/2025_02_02_124012_create_files_table.php new file mode 100644 index 0000000..72a9471 --- /dev/null +++ b/database/migrations/2025_02_02_124012_create_files_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('server_id'); + $table->string('server_user'); + $table->string('path'); + $table->string('type'); + $table->string('name'); + $table->unsignedBigInteger('size'); + $table->unsignedBigInteger('links'); + $table->string('owner'); + $table->string('group'); + $table->string('date'); + $table->string('permissions'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('files'); + } +}; diff --git a/resources/views/ssh/os/delete-file.blade.php b/resources/views/ssh/os/delete-file.blade.php index 974e2a8..b19f126 100644 --- a/resources/views/ssh/os/delete-file.blade.php +++ b/resources/views/ssh/os/delete-file.blade.php @@ -1 +1 @@ -rm -f {{ $path }} +rm -rf {{ $path }} diff --git a/resources/views/ssh/os/extract.blade.php b/resources/views/ssh/os/extract.blade.php new file mode 100644 index 0000000..4635977 --- /dev/null +++ b/resources/views/ssh/os/extract.blade.php @@ -0,0 +1,15 @@ +@php + $extension = pathinfo($path, PATHINFO_EXTENSION); +@endphp + +@if($extension === 'zip') + unzip -o {{ $path }} -d {{ $destination }} +@elseif($extension === 'tar')) + tar -xf {{ $path }} -C {{ $destination }} +@elseif(in_array($extension, ['gz', 'tar.gz'])) + tar -xzf {{ $path }} -C {{ $destination }} +@elseif(in_array($extension, ['bz2', 'tar.bz2'])) + tar -xjf {{ $path }} -C {{ $destination }} +@else + echo "Unsupported archive format: {{ $extension }}" +@endif diff --git a/tests/Feature/FileManagerTest.php b/tests/Feature/FileManagerTest.php new file mode 100644 index 0000000..0827c35 --- /dev/null +++ b/tests/Feature/FileManagerTest.php @@ -0,0 +1,138 @@ +actingAs($this->user); + + $this->get( + Index::getUrl([ + 'server' => $this->server, + ]) + ) + ->assertSuccessful() + ->assertSee('.cache') + ->assertSee('.config'); + } + + public function test_upload_file(): void + { + SSH::fake(); + + $this->actingAs($this->user); + + Livewire::test(FilesList::class, [ + 'server' => $this->server, + ]) + ->callTableAction('upload', null, [ + 'file' => UploadedFile::fake()->create('test.txt'), + ]) + ->assertSuccessful(); + } + + public function test_create_file(): void + { + SSH::fake(<<<'EOF' + total 3 + drwxr-xr-x 7 vito vito 4096 Feb 2 19:42 . + drwxr-xr-x 3 root root 4096 Feb 1 18:44 .. + -rw-rw-r-- 1 vito vito 82 Feb 2 14:13 test.txt + EOF + ); + + $this->actingAs($this->user); + + Livewire::test(FilesList::class, [ + 'server' => $this->server, + ]) + ->callTableAction('new-file', null, [ + 'name' => 'test.txt', + 'content' => 'Hello, world!', + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('files', [ + 'name' => 'test.txt', + ]); + } + + public function test_create_directory(): void + { + SSH::fake(<<<'EOF' + total 3 + drwxr-xr-x 7 vito vito 4096 Feb 2 19:42 . + drwxr-xr-x 3 root root 4096 Feb 1 18:44 .. + drwxr-xr-x 2 vito vito 4096 Feb 2 14:13 test + EOF + ); + + $this->actingAs($this->user); + + Livewire::test(FilesList::class, [ + 'server' => $this->server, + ]) + ->callTableAction('new-directory', null, [ + 'name' => 'test', + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('files', [ + 'name' => 'test', + ]); + } + + public function test_download_file(): void + { + SSH::fake(<<<'EOF' + total 3 + drwxr-xr-x 7 vito vito 4096 Feb 2 19:42 . + drwxr-xr-x 3 root root 4096 Feb 1 18:44 .. + -rw-rw-r-- 1 vito vito 82 Feb 2 14:13 test.txt + EOF + ); + + $this->actingAs($this->user); + + $this->get( + Index::getUrl([ + 'server' => $this->server, + ]) + )->assertSuccessful(); + + $file = File::query()->where('name', 'test.txt')->firstOrFail(); + + Livewire::test(FilesList::class, [ + 'server' => $this->server, + ]) + ->assertTableActionVisible('download', $file) + ->callTableAction('download', $file) + ->assertSuccessful(); + } +}