From 514a100a60eeecfc3f725e3119bdc87b6e026ef0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:53:45 +0200 Subject: [PATCH] feat(terminal): add secure uploaded file lifecycle for imports Persist terminal uploads, add server upload UI and import selection flow, and schedule cleanup for expired or stale files. Tighten upload security, replace ad hoc file cleanup with tracked deletion logic, and cover the new behavior with feature and unit tests. --- .../Terminal/DeleteTerminalUploadedFile.php | 144 ++++ .../Commands/CleanupTerminalUploads.php | 57 ++ app/Console/Kernel.php | 1 + app/Enums/TerminalUploadedFileStatus.php | 12 + app/Http/Controllers/UploadController.php | 118 ++-- app/Jobs/CleanupExpiredTerminalFilesJob.php | 26 +- app/Livewire/Server/Upload.php | 75 +++ app/Livewire/Terminal/FileImport.php | 614 ++++++++++-------- app/Livewire/Terminal/Index.php | 55 +- app/Models/TerminalUploadedFile.php | 104 +++ bootstrap/helpers/filesize.php | 25 - ...0_create_terminal_uploaded_files_table.php | 46 ++ .../views/livewire/server/navbar.blade.php | 5 + .../views/livewire/server/upload.blade.php | 25 + .../livewire/terminal/file-import.blade.php | 315 ++++++--- .../views/livewire/terminal/index.blade.php | 143 ++-- routes/web.php | 5 +- .../CleanupTerminalUploadsCommandTest.php | 76 +++ tests/Feature/ServerUploadPageTest.php | 102 +++ .../TerminalFileImportSecurityTest.php | 138 ++++ .../TerminalImportModalSelectionTest.php | 152 +++++ tests/Feature/TerminalUploadRouteTest.php | 85 +++ tests/Unit/TerminalFileHelperTest.php | 21 + 23 files changed, 1794 insertions(+), 550 deletions(-) create mode 100644 app/Actions/Terminal/DeleteTerminalUploadedFile.php create mode 100644 app/Console/Commands/CleanupTerminalUploads.php create mode 100644 app/Enums/TerminalUploadedFileStatus.php create mode 100644 app/Livewire/Server/Upload.php create mode 100644 app/Models/TerminalUploadedFile.php delete mode 100644 bootstrap/helpers/filesize.php create mode 100644 database/migrations/2026_04_10_130000_create_terminal_uploaded_files_table.php create mode 100644 resources/views/livewire/server/upload.blade.php create mode 100644 tests/Feature/CleanupTerminalUploadsCommandTest.php create mode 100644 tests/Feature/ServerUploadPageTest.php create mode 100644 tests/Feature/TerminalFileImportSecurityTest.php create mode 100644 tests/Feature/TerminalImportModalSelectionTest.php create mode 100644 tests/Feature/TerminalUploadRouteTest.php create mode 100644 tests/Unit/TerminalFileHelperTest.php diff --git a/app/Actions/Terminal/DeleteTerminalUploadedFile.php b/app/Actions/Terminal/DeleteTerminalUploadedFile.php new file mode 100644 index 0000000000..c1f21d073b --- /dev/null +++ b/app/Actions/Terminal/DeleteTerminalUploadedFile.php @@ -0,0 +1,144 @@ +forceFill([ + 'status' => TerminalUploadedFileStatus::Deleting, + 'cleanup_attempts' => $terminalUploadedFile->cleanup_attempts + 1, + 'last_cleanup_error' => null, + ])->save(); + }); + + try { + $server = ! is_null($terminalUploadedFile->server_id) ? Server::find($terminalUploadedFile->server_id) : null; + + if ($server && filled($terminalUploadedFile->server_path)) { + $this->deleteServerFile($server, $terminalUploadedFile->server_path); + } elseif (! is_null($terminalUploadedFile->server_id)) { + Log::warning('Skipping remote terminal file cleanup because server is missing.', [ + 'terminal_uploaded_file_id' => $terminalUploadedFile->id, + 'server_id' => $terminalUploadedFile->server_id, + ]); + } + + if ($server && filled($terminalUploadedFile->container_uuid) && filled($terminalUploadedFile->container_path)) { + $this->deleteContainerFile($server, $terminalUploadedFile->container_uuid, $terminalUploadedFile->container_path); + } + + $this->deleteLocalFile($terminalUploadedFile->local_path); + $this->cleanupParentDirectories($terminalUploadedFile->local_path); + + DB::transaction(function () use ($terminalUploadedFile): void { + $terminalUploadedFile->forceFill([ + 'status' => TerminalUploadedFileStatus::Deleted, + 'deleted_at' => now(), + 'last_cleanup_error' => null, + ])->save(); + }); + } catch (\Throwable $e) { + DB::transaction(function () use ($terminalUploadedFile, $e): void { + $terminalUploadedFile->forceFill([ + 'status' => TerminalUploadedFileStatus::DeleteFailed, + 'last_cleanup_error' => $e->getMessage(), + ])->save(); + }); + + throw $e; + } + } + + private function deleteServerFile(Server $server, string $serverPath): void + { + $escapedServerPath = escapeshellarg($serverPath); + + instant_remote_process([ + "rm -f -- {$escapedServerPath}", + ], $server, throwError: false); + + $remoteStillExists = instant_remote_process([ + "test ! -e {$escapedServerPath} && echo deleted || echo exists", + ], $server, throwError: false); + + if ($remoteStillExists !== 'deleted') { + throw new \RuntimeException("Failed to delete remote terminal file: {$serverPath}"); + } + } + + private function deleteContainerFile(Server $server, string $containerUuid, string $containerPath): void + { + $escapedContainerUuid = escapeshellarg($containerUuid); + $escapedContainerPath = escapeshellarg($containerPath); + + instant_remote_process([ + "docker exec -u 0 {$escapedContainerUuid} rm -f -- {$escapedContainerPath} 2>/dev/null || true", + ], $server, throwError: false); + + $containerDeletionState = instant_remote_process([ + "if docker container inspect {$escapedContainerUuid} >/dev/null 2>&1; then docker exec -u 0 {$escapedContainerUuid} test ! -e {$escapedContainerPath} 2>/dev/null && echo deleted || echo exists; else echo deleted; fi", + ], $server, throwError: false); + + if ($containerDeletionState !== 'deleted') { + throw new \RuntimeException("Failed to delete container terminal file: {$containerPath}"); + } + } + + private function deleteLocalFile(?string $localPath): void + { + if (blank($localPath)) { + return; + } + + if (file_exists($localPath)) { + unlink($localPath); + } + } + + private function cleanupParentDirectories(?string $localPath): void + { + if (blank($localPath)) { + return; + } + + $allowedRoots = array_filter([ + realpath(storage_path('app/terminal-uploads')), + realpath(storage_path('app/terminal-uploads-pending')), + ]); + + $currentDirectory = dirname($localPath); + + while ($currentDirectory && $currentDirectory !== '.' && $currentDirectory !== DIRECTORY_SEPARATOR) { + $realCurrentDirectory = realpath($currentDirectory); + + if ($realCurrentDirectory === false) { + break; + } + + $isWithinAllowedRoot = collect($allowedRoots)->contains( + fn (string $root): bool => str_starts_with($realCurrentDirectory, $root) + ); + + if (! $isWithinAllowedRoot) { + break; + } + + $contents = scandir($realCurrentDirectory); + if ($contents === false || count($contents) > 2) { + break; + } + + rmdir($realCurrentDirectory); + $currentDirectory = dirname($realCurrentDirectory); + } + } +} diff --git a/app/Console/Commands/CleanupTerminalUploads.php b/app/Console/Commands/CleanupTerminalUploads.php new file mode 100644 index 0000000000..d287825d88 --- /dev/null +++ b/app/Console/Commands/CleanupTerminalUploads.php @@ -0,0 +1,57 @@ +option('chunk'), 1); + $pendingHours = max((int) $this->option('pending-hours'), 1); + + $expiredCount = 0; + $stalePendingCount = 0; + + TerminalUploadedFile::query() + ->expiredForCleanup() + ->orderBy('id') + ->chunkById($chunkSize, function (Collection $files) use ($deleteTerminalUploadedFile, &$expiredCount): void { + foreach ($files as $file) { + try { + $deleteTerminalUploadedFile($file); + $expiredCount++; + } catch (\Throwable $e) { + $this->warn("Failed to clean expired terminal upload {$file->uuid}: {$e->getMessage()}"); + } + } + }); + + TerminalUploadedFile::query() + ->pendingForCleanup($pendingHours) + ->orderBy('id') + ->chunkById($chunkSize, function (Collection $files) use ($deleteTerminalUploadedFile, &$stalePendingCount): void { + foreach ($files as $file) { + try { + $deleteTerminalUploadedFile($file); + $stalePendingCount++; + } catch (\Throwable $e) { + $this->warn("Failed to clean pending terminal upload {$file->uuid}: {$e->getMessage()}"); + } + } + }); + + $this->info("Cleaned {$expiredCount} expired terminal uploads."); + $this->info("Cleaned {$stalePendingCount} stale pending terminal uploads."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5e12b7ee1..0095f028ff 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -41,6 +41,7 @@ class Kernel extends ConsoleKernel // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); + $this->scheduleInstance->command('cleanup:terminal-uploads')->hourly()->withoutOverlapping()->onOneServer(); if (isDev()) { // Instance Jobs diff --git a/app/Enums/TerminalUploadedFileStatus.php b/app/Enums/TerminalUploadedFileStatus.php new file mode 100644 index 0000000000..4658090773 --- /dev/null +++ b/app/Enums/TerminalUploadedFileStatus.php @@ -0,0 +1,12 @@ +route('databaseUuid'); $resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id')); @@ -23,15 +27,12 @@ class UploadController extends BaseController $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { - throw new UploadMissingFileException(); + throw new UploadMissingFileException; } $save = $receiver->receive(); if ($save->isFinished()) { - // Use the original identifier from the route to maintain path consistency - // For ServiceDatabase: {name}-{service_uuid} - // For standalone databases: {uuid} return $this->saveFile($save->getFile(), $databaseIdentifier); } @@ -42,30 +43,10 @@ class UploadController extends BaseController 'status' => true, ]); } - // protected function saveFileToS3($file) - // { - // $fileName = $this->createFilename($file); - // $disk = Storage::disk('s3'); - // // It's better to use streaming Streaming (laravel 5.4+) - // $disk->putFileAs('photos', $file, $fileName); - - // // for older laravel - // // $disk->put($fileName, file_get_contents($file), 'public'); - // $mime = str_replace('/', '-', $file->getMimeType()); - - // // We need to delete the file when uploaded to s3 - // unlink($file->getPathname()); - - // return response()->json([ - // 'path' => $disk->url($fileName), - // 'name' => $fileName, - // 'mime_type' => $mime - // ]); - // } - protected function saveFile(UploadedFile $file, string $resourceIdentifier) + protected function saveFile(UploadedFile $file, string $resourceIdentifier): JsonResponse { - $mime = str_replace('/', '-', $file->getMimeType()); + $mime = str_replace('/', '-', (string) $file->getMimeType()); $filePath = "upload/{$resourceIdentifier}"; $finalPath = storage_path('app/'.$filePath); $file->move($finalPath, 'restore'); @@ -75,29 +56,24 @@ class UploadController extends BaseController ]); } - public function uploadTerminalFile(Request $request) + public function uploadTerminalFile(Request $request): JsonResponse { - // Security: Verify user has permission to upload terminal files if (! Auth::check()) { return response()->json(['error' => 'Unauthorized'], 401); } - // Check if user is admin or has terminal access - $user = Auth::user(); - if (! $user->isAdmin() && ! $user->isInstanceAdmin()) { - return response()->json(['error' => 'You do not have permission to upload terminal files'], 403); - } + Gate::authorize('canAccessTerminal'); $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { - throw new UploadMissingFileException(); + throw new UploadMissingFileException; } $save = $receiver->receive(); if ($save->isFinished()) { - return $this->saveTerminalFile($save->getFile()); + return $this->saveTerminalFile($save->getFile(), $request); } $handler = $save->handler(); @@ -108,39 +84,75 @@ class UploadController extends BaseController ]); } - protected function saveTerminalFile(UploadedFile $file) + protected function saveTerminalFile(UploadedFile $file, Request $request): JsonResponse { - $mime = str_replace('/', '-', $file->getMimeType()); - $filePath = 'terminal-uploads/temp'; + $user = Auth::user(); + $teamId = data_get($user?->currentTeam(), 'id'); + + if (blank($teamId)) { + return response()->json(['error' => 'No active team found.'], 422); + } + + $mime = str_replace('/', '-', (string) $file->getMimeType()); + $size = (int) $file->getSize(); + $originalName = $file->getClientOriginalName(); + $extension = $file->getClientOriginalExtension(); + + $uploadToken = (string) $request->input('resumableIdentifier', Str::uuid()->toString()); + $uploadToken = preg_replace('/[^a-zA-Z0-9_-]/', '', $uploadToken) ?: Str::uuid()->toString(); + + $filePath = sprintf('terminal-uploads-pending/user_%d/%s', $user->id, $uploadToken); $finalPath = storage_path('app/'.$filePath); - // Create directory if it doesn't exist if (! is_dir($finalPath)) { mkdir($finalPath, 0755, true); } - // Security: Generate safe filename server-side to prevent path traversal - $originalName = $file->getClientOriginalName(); - $extension = $file->getClientOriginalExtension(); - - // Create a safe slug from original filename (without extension) $nameWithoutExt = pathinfo($originalName, PATHINFO_FILENAME); - $safeSlug = Str::slug($nameWithoutExt); // Converts to lowercase, replaces special chars with dashes - $safeSlug = substr($safeSlug, 0, 50); // Limit length - - // Sanitize extension (only allow alphanumeric) + $safeSlug = Str::slug($nameWithoutExt); + $safeSlug = substr($safeSlug, 0, 50); $safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension); - - // Generate safe filename: timestamp_slug_randomhash.ext $randomHash = Str::random(16); $safeFilename = time().'_'.$safeSlug.'_'.$randomHash.($safeExtension ? '.'.$safeExtension : ''); $file->move($finalPath, $safeFilename); + $localPath = $finalPath.'/'.$safeFilename; + + $terminalUploadedFile = TerminalUploadedFile::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'team_id' => $teamId, + 'upload_token' => $uploadToken, + ], + [ + 'original_name' => $originalName, + 'stored_filename' => $safeFilename, + 'mime_type' => $mime, + 'size_bytes' => $size, + 'local_path' => $localPath, + 'server_id' => null, + 'server_path' => null, + 'container_uuid' => null, + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Pending, + 'uploaded_at' => now(), + 'expires_at' => null, + 'finalized_at' => null, + 'deleted_at' => null, + 'last_cleanup_error' => null, + ], + ); + return response()->json([ + 'done' => 100, + 'status' => true, + 'file_uuid' => $terminalUploadedFile->uuid, 'mime_type' => $mime, - 'filename' => $safeFilename, - 'original_name' => $originalName, // Keep original name for reference + 'size' => $size, + 'upload_token' => $uploadToken, + 'stored_filename' => $safeFilename, + 'original_name' => $originalName, ]); } } diff --git a/app/Jobs/CleanupExpiredTerminalFilesJob.php b/app/Jobs/CleanupExpiredTerminalFilesJob.php index 706e3e7e03..8eff2f47ed 100644 --- a/app/Jobs/CleanupExpiredTerminalFilesJob.php +++ b/app/Jobs/CleanupExpiredTerminalFilesJob.php @@ -9,7 +9,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; class CleanupExpiredTerminalFilesJob implements ShouldQueue { @@ -26,8 +25,7 @@ class CleanupExpiredTerminalFilesJob implements ShouldQueue public int $serverId, public ?string $containerUuid, public string $filename - ) { - } + ) {} public function handle(): void { @@ -43,25 +41,35 @@ class CleanupExpiredTerminalFilesJob implements ShouldQueue if ($server) { // Remove from server - escape shell arguments to prevent injection $escapedServerPath = escapeshellarg($this->serverPath); - $result = instant_remote_process([ - "rm -f {$escapedServerPath}" + instant_remote_process([ + "rm -f -- {$escapedServerPath}", ], $server, throwError: false); - if ($result) { + $remoteStillExists = instant_remote_process([ + "if [ -e {$escapedServerPath} ]; then echo exists; fi", + ], $server, throwError: false); + + if ($remoteStillExists !== 'exists') { Log::info("Cleaned up server terminal file: {$this->serverPath}"); } // If container was specified, remove from container as well if ($this->containerUuid) { $escapedContainerUuid = escapeshellarg($this->containerUuid); - $escapedFilename = escapeshellarg($this->filename); $containerPath = "/tmp/{$this->filename}"; // For logging only + $escapedContainerPath = escapeshellarg($containerPath); instant_remote_process([ - "docker exec {$escapedContainerUuid} rm -f /tmp/{$escapedFilename} 2>/dev/null || true" + "docker exec -u 0 {$escapedContainerUuid} rm -f -- {$escapedContainerPath} 2>/dev/null || true", ], $server, throwError: false); - Log::info("Cleaned up container terminal file: {$containerPath}"); + $containerStillExists = instant_remote_process([ + "docker exec -u 0 {$escapedContainerUuid} test ! -e {$escapedContainerPath} 2>/dev/null && echo deleted || echo exists", + ], $server, throwError: false); + + if ($containerStillExists === 'deleted') { + Log::info("Cleaned up container terminal file: {$containerPath}"); + } } } diff --git a/app/Livewire/Server/Upload.php b/app/Livewire/Server/Upload.php new file mode 100644 index 0000000000..c59845c14f --- /dev/null +++ b/app/Livewire/Server/Upload.php @@ -0,0 +1,75 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->servers = [ + [ + 'uuid' => $this->server->uuid, + 'name' => $this->server->name, + ], + ]; + $this->containers = $this->runningContainers(); + } + + public function render() + { + return view('livewire.server.upload'); + } + + /** + * @return array + */ + private function runningContainers(): array + { + if (! $this->server->isFunctional() || ! $this->server->isTerminalEnabled()) { + return []; + } + + try { + return $this->server + ->loadAllContainers() + ->map(function (array $container): ?array { + $state = data_get_str($container, 'State')->lower(); + if (! $state->contains('running')) { + return null; + } + + $name = data_get($container, 'Names'); + if (blank($name)) { + return null; + } + + return [ + 'name' => $name, + 'uuid' => $name, + 'server_uuid' => $this->server->uuid, + ]; + }) + ->filter() + ->sortBy('name', SORT_NATURAL) + ->values() + ->all(); + } catch (\Throwable $e) { + report($e); + + return []; + } + } +} diff --git a/app/Livewire/Terminal/FileImport.php b/app/Livewire/Terminal/FileImport.php index 5bb03a1cb7..a15d2a7a6a 100644 --- a/app/Livewire/Terminal/FileImport.php +++ b/app/Livewire/Terminal/FileImport.php @@ -2,28 +2,29 @@ namespace App\Livewire\Terminal; +use App\Actions\Terminal\DeleteTerminalUploadedFile; +use App\Enums\TerminalUploadedFileStatus; use App\Helpers\TerminalFileHelper; -use App\Jobs\CleanupExpiredTerminalFilesJob; use App\Models\Server; +use App\Models\TerminalUploadedFile; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; -use Livewire\WithFileUploads; class FileImport extends Component { use AuthorizesRequests; - use WithFileUploads; - public string $selectedUuid; + public string $selectedUuid = 'default'; - public ?string $selectedServerUuid = null; + #[Locked] + public array $servers = []; - public ?string $targetName = null; - - public $uploadedFile; + #[Locked] + public array $containers = []; public ?string $filename = null; @@ -37,25 +38,28 @@ class FileImport extends Component public bool $error = false; - public int $expirationMinutes = 60; // Default: 1 hour + public int $expirationMinutes = 60; - public function updatedUploadedFile() + public bool $hasPendingUpload = false; + + public ?string $pendingUploadUuid = null; + + public ?string $pendingDeleteFileUuid = null; + + public function mount(array $servers, array $containers, string $selectedUuid = 'default'): void { - $this->validate([ - 'uploadedFile' => 'required|file|max:10485760', // 10GB in KB - ]); + $this->authorizeTerminalAccess(); - $this->filename = $this->uploadedFile->getClientOriginalName(); - $this->filesize = number_format($this->uploadedFile->getSize() / 1024 / 1024, 2) . ' MB'; - $this->isUploading = false; - - $this->dispatch('success', 'File uploaded successfully!'); + $this->servers = $servers; + $this->containers = $containers; + $this->selectedUuid = $this->isSelectableTarget($selectedUuid) ? $selectedUuid : 'default'; } #[Computed] - public function expirationOptions() + public function expirationOptions(): array { return [ + 1 => '1 minute', 5 => '5 minutes', 15 => '15 minutes', 30 => '30 minutes', @@ -68,276 +72,269 @@ class FileImport extends Component } #[Computed] - public function uploadedFiles() + public function uploadedFiles(): array { - $files = []; - $baseDir = storage_path('app/terminal-uploads'); - $currentUserId = Auth::id(); - - if (! is_dir($baseDir)) { - return $files; - } - - // Scan subdirectories for current user's uploaded files - $directories = glob($baseDir . '/user_' . $currentUserId . '_*', GLOB_ONLYDIR); - - // Collect all server IDs to load them in bulk - $serverIds = []; - $filesData = []; - - foreach ($directories as $dir) { - $dirFiles = glob($dir . '/*'); - foreach ($dirFiles as $filePath) { - if (is_file($filePath)) { - $filename = basename($filePath); - - // Parse metadata from filename - $metadata = TerminalFileHelper::parseFilename($filename); - - if (!$metadata) { - // Skip files that don't match our format - continue; - } - - $serverIds[] = $metadata['server_id']; - $filesData[] = [ - 'filename' => $filename, - 'display_name' => TerminalFileHelper::getDisplayName($filename), - 'directory' => basename($dir), - 'size' => filesize($filePath), - 'uploaded_at' => $metadata['uploaded_at'], - 'expires_at' => $metadata['expires_at'], - 'server_id' => $metadata['server_id'], - 'container_uuid' => $metadata['container_uuid'], - ]; - } - } - } - - // Load all servers in one query - $servers = Server::whereIn('id', array_unique($serverIds))->get()->keyBy('id'); - - // Add server name to each file - foreach ($filesData as $fileData) { - $server = $servers->get($fileData['server_id']); - $fileData['server_name'] = $server ? $server->name : 'Unknown'; - $files[] = $fileData; - } - - // Sort by upload date (newest first) - usort($files, function ($a, $b) { - return $b['uploaded_at'] <=> $a['uploaded_at']; - }); - - return $files; + return TerminalUploadedFile::query() + ->ownedByCurrentUserAndTeam() + ->visible() + ->with('server:id,name') + ->orderByDesc('uploaded_at') + ->get() + ->map(function (TerminalUploadedFile $terminalUploadedFile): array { + return [ + 'uuid' => $terminalUploadedFile->uuid, + 'filename' => $terminalUploadedFile->stored_filename, + 'display_name' => $terminalUploadedFile->original_name ?: TerminalFileHelper::getDisplayName($terminalUploadedFile->stored_filename), + 'size' => $terminalUploadedFile->size_bytes, + 'uploaded_at' => $terminalUploadedFile->uploaded_at?->timestamp, + 'expires_at' => $terminalUploadedFile->expires_at?->timestamp, + 'server_name' => $terminalUploadedFile->server?->name ?? 'Unknown', + 'container_uuid' => $terminalUploadedFile->container_uuid, + 'path' => $terminalUploadedFile->container_path ?: $terminalUploadedFile->server_path, + ]; + }) + ->all(); } - public function copyPath(string $directory, string $filename) + #[Computed] + public function targetName(): ?string { - // Security: validate inputs to prevent path traversal - if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || - strpos($directory, '..') !== false || strpos($directory, '/') !== false) { - $this->dispatch('error', 'Invalid filename or directory'); + return data_get($this->resolveSelectedTarget(), 'label'); + } + + public function updatedSelectedUuid(string $value): void + { + $this->authorizeTerminalAccess(); + + if ($value === 'default') { + $this->filePath = null; + $this->error = false; + return; } - // Security: verify directory belongs to current user - $currentUserId = Auth::id(); - if (! str_starts_with($directory, 'user_' . $currentUserId . '_')) { - $this->dispatch('error', 'Unauthorized access'); + if (! $this->isSelectableTarget($value)) { + $this->selectedUuid = 'default'; + $this->dispatchError('Invalid target selected.'); + return; } - // Parse metadata from filename - $metadata = TerminalFileHelper::parseFilename($filename); + $this->filePath = null; + $this->error = false; + } + + public function registerUploadedFile(string $fileUuid, string $originalName, int $size): void + { + $this->authorizeTerminalAccess(); + + $terminalUploadedFile = $this->findPendingUploadForCurrentUser($fileUuid); + + if (! $terminalUploadedFile) { + $this->dispatchError('Uploaded file not found. Please try again.'); - if (!$metadata) { - $this->dispatch('error', 'Invalid file format'); return; } - // Generate server path - $serverPath = TerminalFileHelper::generateServerPath($filename); + $this->error = false; + $this->pendingUploadUuid = $terminalUploadedFile->uuid; + $this->filename = basename($originalName); + $this->filesize = formatBytes($size); + $this->filePath = null; + $this->hasPendingUpload = true; + $this->isUploading = false; + $this->progress = 100; - // Return the actual server path - $this->dispatch('path-copied', path: $serverPath); - $this->dispatch('success', 'Path copied to clipboard!'); - } - - public function deleteFile(string $directory, string $filename) - { - // Security: validate inputs to prevent path traversal - if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || - strpos($directory, '..') !== false || strpos($directory, '/') !== false) { - $this->dispatch('error', 'Invalid filename or directory'); - return; - } - - // Security: verify directory belongs to current user - $currentUserId = Auth::id(); - if (! str_starts_with($directory, 'user_' . $currentUserId . '_')) { - $this->dispatch('error', 'Unauthorized access'); - return; - } - - try { - // Parse metadata from filename - $metadata = TerminalFileHelper::parseFilename($filename); - - if (!$metadata) { - $this->dispatch('error', 'Invalid file format'); - return; - } - - // Find and delete the file - $baseDir = storage_path('app/terminal-uploads'); - $dir = $baseDir . '/' . $directory; - $filePath = $dir . '/' . $filename; - - if (! file_exists($filePath)) { - $this->dispatch('error', 'File not found'); - return; - } - - // Security: double-check the full path contains user ID - $realPath = realpath($filePath); - $realBaseDir = realpath($baseDir); - - if ($realPath === false || $realBaseDir === false || - ! str_starts_with($realPath, $realBaseDir . '/user_' . $currentUserId . '_')) { - $this->dispatch('error', 'Unauthorized access'); - return; - } - - // Delete local file - unlink($filePath); - - // Delete remote files if server exists - $server = Server::find($metadata['server_id']); - - if ($server) { - // Generate server path - $serverPath = TerminalFileHelper::generateServerPath($filename); - $escapedServerPath = escapeshellarg($serverPath); - - instant_remote_process([ - "rm -f {$escapedServerPath}" - ], $server, throwError: false); - - // Delete from container if applicable - if ($metadata['container_uuid']) { - $escapedContainerUuid = escapeshellarg($metadata['container_uuid']); - $containerPath = TerminalFileHelper::generateContainerPath($filename); - $escapedContainerPath = escapeshellarg($containerPath); - - instant_remote_process([ - "docker exec {$escapedContainerUuid} rm -f {$escapedContainerPath} 2>/dev/null || true" - ], $server, throwError: false); - } - } - - // Try to delete the empty directory - $files = scandir($dir); - if (count($files) === 2) { // Only . and .. - rmdir($dir); - } - - $this->dispatch('success', 'File deleted successfully!'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function getListeners() - { - $userId = Auth::id(); - - return [ - "echo-private:user.{$userId},FileUploadCompleted" => 'handleUploadCompleted', - ]; - } - - public function mount(string $selectedUuid, string $targetName, ?string $selectedServerUuid = null) - { - $this->selectedUuid = $selectedUuid; - $this->targetName = $targetName; - $this->selectedServerUuid = $selectedServerUuid; - } - - public function handleUploadCompleted() - { - // Refresh the component after upload $this->dispatch('success', 'File uploaded successfully!'); } - public function generateFilePath() + public function resetPendingUpload(): void { - if (empty($this->filename)) { - $this->dispatch('error', 'No file uploaded yet.'); + $this->authorizeTerminalAccess(); + + $terminalUploadedFile = $this->findPendingUploadForCurrentUser($this->pendingUploadUuid); + if ($terminalUploadedFile) { + app(DeleteTerminalUploadedFile::class)($terminalUploadedFile); + } + + $this->clearUploadState(); + } + + public function handleUploadError(string $message): void + { + $this->authorizeTerminalAccess(); + $this->dispatchError($message); + } + + public function copyPath(string $fileUuid): void + { + $this->authorizeTerminalAccess(); + + $terminalUploadedFile = $this->findVisibleFileForCurrentUser($fileUuid); + + if (! $terminalUploadedFile) { + $this->dispatchError('File not found.'); + + return; + } + + $path = $terminalUploadedFile->container_path ?: $terminalUploadedFile->server_path; + + if (blank($path)) { + $this->dispatchError('File path is unavailable.'); + + return; + } + + $this->dispatch('path-copied', path: $path); + $this->dispatch('success', 'Path copied to clipboard!'); + } + + public function prepareFileDeletion(string $fileUuid): void + { + $this->authorizeTerminalAccess(); + + $terminalUploadedFile = $this->findVisibleFileForCurrentUser($fileUuid); + + if (! $terminalUploadedFile) { + $this->pendingDeleteFileUuid = null; + $this->dispatchError('File not found.'); + + return; + } + + $this->pendingDeleteFileUuid = $terminalUploadedFile->uuid; + } + + public function confirmDeleteFile(): void + { + $this->authorizeTerminalAccess(); + + $terminalUploadedFile = $this->findVisibleFileForCurrentUser((string) $this->pendingDeleteFileUuid); + + if (! $terminalUploadedFile) { + $this->pendingDeleteFileUuid = null; + $this->dispatchError('File not found.'); + + return; + } + + $this->deleteTerminalUploadedFile($terminalUploadedFile); + $this->pendingDeleteFileUuid = null; + } + + public function deleteFile(string $fileUuid): void + { + $this->authorizeTerminalAccess(); + + $terminalUploadedFile = $this->findVisibleFileForCurrentUser($fileUuid); + + if (! $terminalUploadedFile) { + $this->dispatchError('File not found.'); + + return; + } + + $this->deleteTerminalUploadedFile($terminalUploadedFile); + } + + private function deleteTerminalUploadedFile(TerminalUploadedFile $terminalUploadedFile): void + { + try { + app(DeleteTerminalUploadedFile::class)($terminalUploadedFile); + $this->error = false; + $this->dispatch('success', 'File deleted successfully!'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function generateFilePath(): void + { + $this->authorizeTerminalAccess(); + + $target = $this->resolveSelectedTarget(); + if (! $target) { + $this->dispatchError('Please select a server or container.'); + + return; + } + + $pendingUpload = $this->findPendingUploadForCurrentUser($this->pendingUploadUuid); + + if (! $pendingUpload || blank($this->filename)) { + $this->dispatchError('No file uploaded yet.'); return; } try { - // Determine server UUID - $serverUuid = $this->selectedServerUuid ?? $this->selectedUuid; - $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->first(); + $server = Server::ownedByCurrentTeam()->whereUuid($target['server_uuid'])->first(); if (! $server) { - $this->dispatch('error', 'Server not found.'); + $this->dispatchError('Server not found.'); return; } - $isContainer = ($this->selectedServerUuid && $this->selectedUuid !== $this->selectedServerUuid); + if (! is_file($pendingUpload->local_path)) { + $this->dispatchError('Uploaded file not found. Please upload it again.'); - // Generate unique file identifier - $uploadId = uniqid('terminal_', true); - $originalFilename = basename($this->filename); - $userId = Auth::id(); + return; + } - // Calculate expiration timestamp - $expiresAt = now()->addMinutes($this->expirationMinutes)->timestamp; + $realPendingPath = realpath($pendingUpload->local_path); + $realPendingBaseDir = realpath($this->pendingUploadsBaseDirectory()); - // Generate filename with embedded metadata + if ($realPendingPath === false || $realPendingBaseDir === false || ! str_starts_with($realPendingPath, $realPendingBaseDir)) { + $this->dispatchError('Unauthorized upload path.'); + + return; + } + + $isContainer = $target['is_container']; + $expiresAt = now()->addMinutes($this->expirationMinutes); $sanitizedFilename = TerminalFileHelper::generateFilename( - $originalFilename, - $expiresAt, + $pendingUpload->original_name, + $expiresAt->timestamp, $server->id, - $isContainer ? $this->selectedUuid : null + $isContainer ? $target['uuid'] : null, ); - $storageDir = "terminal-uploads/user_{$userId}_{$uploadId}"; + $storageDir = sprintf('terminal-uploads/user_%d_%s', Auth::id(), $pendingUpload->upload_token); $storagePath = storage_path("app/{$storageDir}"); - // Get the uploaded file from Livewire - if (! $this->uploadedFile) { - $this->dispatch('error', 'Uploaded file not found. Please try uploading again.'); - - return; - } - - // Create directory and store file - if (! file_exists($storagePath)) { + if (! is_dir($storagePath)) { mkdir($storagePath, 0755, true); } $finalPath = "{$storagePath}/{$sanitizedFilename}"; - $this->uploadedFile->storeAs($storageDir, $sanitizedFilename); - - // Generate server path $serverTmpPath = TerminalFileHelper::generateServerPath($sanitizedFilename); $safeServerTmpPath = escapeshellarg($serverTmpPath); - instant_scp($finalPath, $safeServerTmpPath, $server); - // If it's a container, copy to container + if (! rename($pendingUpload->local_path, $finalPath)) { + $this->dispatchError('Failed to finalize uploaded file. Please try again.'); + + return; + } + + $this->cleanupDirectoryIfEmpty(dirname($pendingUpload->local_path)); + $this->cleanupDirectoryIfEmpty($this->pendingUploadsBaseDirectory()); + + instant_scp($finalPath, $safeServerTmpPath, $server); + instant_remote_process([ + "chmod 600 {$safeServerTmpPath}", + ], $server); + + $containerPath = null; if ($isContainer) { $containerPath = TerminalFileHelper::generateContainerPath($sanitizedFilename); - $safeContainer = escapeshellarg($this->selectedUuid); + $safeContainer = escapeshellarg($target['uuid']); $safeContainerPath = escapeshellarg($containerPath); instant_remote_process([ "docker cp {$safeServerTmpPath} {$safeContainer}:{$safeContainerPath}", + "docker exec -u 0 {$safeContainer} chmod 600 {$safeContainerPath}", ], $server); $this->filePath = $containerPath; @@ -345,18 +342,25 @@ class FileImport extends Component $this->filePath = $serverTmpPath; } - // Schedule cleanup job - CleanupExpiredTerminalFilesJob::dispatch( - $finalPath, - $serverTmpPath, - $server->id, - $isContainer ? $this->selectedUuid : null, - $sanitizedFilename - )->delay(now()->addMinutes($this->expirationMinutes)); + $pendingUpload->forceFill([ + 'server_id' => $server->id, + 'container_uuid' => $isContainer ? $target['uuid'] : null, + 'stored_filename' => $sanitizedFilename, + 'local_path' => $finalPath, + 'server_path' => $serverTmpPath, + 'container_path' => $containerPath, + 'status' => TerminalUploadedFileStatus::Active, + 'expires_at' => $expiresAt, + 'finalized_at' => now(), + 'last_cleanup_error' => null, + ])->save(); + $this->pendingUploadUuid = null; + $this->hasPendingUpload = false; + $this->error = false; $this->dispatch('success', "File ready! Path: {$this->filePath}"); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } @@ -365,16 +369,112 @@ class FileImport extends Component return view('livewire.terminal.file-import'); } - /** - * Workaround for Livewire toJSON serialization issue - * This prevents "Public method [toJSON] not found" errors - */ - public function toJSON() + private function authorizeTerminalAccess(): void { - return json_encode([ - 'selectedUuid' => $this->selectedUuid, - 'targetName' => $this->targetName, - 'selectedServerUuid' => $this->selectedServerUuid, - ]); + Gate::authorize('canAccessTerminal'); + } + + private function pendingUploadsBaseDirectory(): string + { + return storage_path('app/terminal-uploads-pending/user_'.Auth::id()); + } + + private function cleanupDirectoryIfEmpty(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $contents = scandir($directory); + if ($contents !== false && count($contents) === 2) { + rmdir($directory); + } + } + + private function clearUploadState(bool $resetProgress = true): void + { + $this->hasPendingUpload = false; + $this->pendingUploadUuid = null; + $this->filePath = null; + $this->isUploading = false; + $this->error = false; + + if ($resetProgress) { + $this->progress = 0; + } + } + + private function dispatchError(string $message): void + { + $this->error = true; + $this->isUploading = false; + $this->dispatch('error', $message); + } + + private function isSelectableTarget(string $uuid): bool + { + return $this->resolveTargetByUuid($uuid) !== null; + } + + private function findPendingUploadForCurrentUser(?string $fileUuid): ?TerminalUploadedFile + { + if (blank($fileUuid)) { + return null; + } + + return TerminalUploadedFile::query() + ->ownedByCurrentUserAndTeam() + ->where('uuid', $fileUuid) + ->whereNull('deleted_at') + ->whereNull('finalized_at') + ->where('status', TerminalUploadedFileStatus::Pending) + ->first(); + } + + private function findVisibleFileForCurrentUser(string $fileUuid): ?TerminalUploadedFile + { + return TerminalUploadedFile::query() + ->ownedByCurrentUserAndTeam() + ->visible() + ->where('uuid', $fileUuid) + ->first(); + } + + /** + * @return array{uuid:string,server_uuid:string,label:string,is_container:bool}|null + */ + private function resolveSelectedTarget(): ?array + { + return $this->resolveTargetByUuid($this->selectedUuid); + } + + /** + * @return array{uuid:string,server_uuid:string,label:string,is_container:bool}|null + */ + private function resolveTargetByUuid(string $uuid): ?array + { + foreach ($this->servers as $server) { + if (($server['uuid'] ?? null) === $uuid) { + return [ + 'uuid' => $uuid, + 'server_uuid' => $uuid, + 'label' => sprintf('%s (Server)', $server['name']), + 'is_container' => false, + ]; + } + } + + foreach ($this->containers as $container) { + if (($container['uuid'] ?? null) === $uuid && filled($container['server_uuid'] ?? null)) { + return [ + 'uuid' => $uuid, + 'server_uuid' => $container['server_uuid'], + 'label' => $container['name'] ?? $uuid, + 'is_container' => true, + ]; + } + } + + return null; } } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 5caa8e5af8..5f2ed98eb6 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -18,6 +18,8 @@ class Index extends Component public bool $showImportModal = false; + public int $importModalIteration = 0; + public function mount() { $this->servers = Server::isReachable()->get()->filter(function ($server) { @@ -64,9 +66,9 @@ class Index extends Component public function updatedSelectedUuid() { if ($this->selected_uuid === 'default') { - // When cleared to default, do nothing (no error message) return; } + $this->connectToContainer(); } @@ -78,62 +80,23 @@ class Index extends Component return; } + $container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid); - $this->dispatch('send-terminal-command', + $this->dispatch( + 'send-terminal-command', isset($container), $container['connection_name'] ?? $this->selected_uuid, $container['server_uuid'] ?? $this->selected_uuid ); } - public function getTargetName() - { - if ($this->selected_uuid === 'default') { - return ''; - } - - // Check if it's a server - $server = collect($this->servers)->firstWhere('uuid', $this->selected_uuid); - if ($server) { - return $server->name.' (Server)'; - } - - // Otherwise it's a container - $container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid); - if ($container) { - return $container['name']; - } - - return ''; - } - - public function getServerUuid() - { - if ($this->selected_uuid === 'default') { - return null; - } - - // Check if it's a server - $server = collect($this->servers)->firstWhere('uuid', $this->selected_uuid); - if ($server) { - return $server->uuid; - } - - // Otherwise it's a container, get its server - $container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid); - if ($container) { - return $container['server_uuid']; - } - - return null; - } - - public function openImportModal() + public function openImportModal(): void { + $this->importModalIteration++; $this->showImportModal = true; } - public function closeImportModal() + public function closeImportModal(): void { $this->showImportModal = false; } diff --git a/app/Models/TerminalUploadedFile.php b/app/Models/TerminalUploadedFile.php new file mode 100644 index 0000000000..55f9cca7a1 --- /dev/null +++ b/app/Models/TerminalUploadedFile.php @@ -0,0 +1,104 @@ + TerminalUploadedFileStatus::class, + 'size_bytes' => 'integer', + 'cleanup_attempts' => 'integer', + 'uploaded_at' => 'datetime', + 'expires_at' => 'datetime', + 'finalized_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function scopeOwnedByCurrentUserAndTeam(Builder $query): Builder + { + return $query + ->where('user_id', auth()->id()) + ->where('team_id', data_get(auth()->user()?->currentTeam(), 'id')); + } + + public function scopeVisible(Builder $query): Builder + { + return $query + ->whereNull('deleted_at') + ->whereNotNull('finalized_at') + ->whereIn('status', [ + TerminalUploadedFileStatus::Active, + TerminalUploadedFileStatus::DeleteFailed, + TerminalUploadedFileStatus::Deleting, + ]); + } + + public function scopeExpiredForCleanup(Builder $query): Builder + { + return $query + ->whereNull('deleted_at') + ->whereNotNull('finalized_at') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->whereIn('status', [ + TerminalUploadedFileStatus::Active, + TerminalUploadedFileStatus::DeleteFailed, + ]); + } + + public function scopePendingForCleanup(Builder $query, int $hours): Builder + { + return $query + ->whereNull('deleted_at') + ->whereNull('finalized_at') + ->where('uploaded_at', '<=', now()->subHours($hours)) + ->whereIn('status', [ + TerminalUploadedFileStatus::Pending, + TerminalUploadedFileStatus::DeleteFailed, + ]); + } +} diff --git a/bootstrap/helpers/filesize.php b/bootstrap/helpers/filesize.php deleted file mode 100644 index 09ef3c3652..0000000000 --- a/bootstrap/helpers/filesize.php +++ /dev/null @@ -1,25 +0,0 @@ -id(); + $table->string('uuid')->unique(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_id')->nullable()->constrained()->nullOnDelete(); + $table->string('upload_token'); + $table->string('container_uuid')->nullable(); + $table->string('original_name'); + $table->string('stored_filename'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('size_bytes')->default(0); + $table->text('local_path'); + $table->text('server_path')->nullable(); + $table->text('container_path')->nullable(); + $table->string('status')->default('pending'); + $table->timestamp('uploaded_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('finalized_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('cleanup_attempts')->default(0); + $table->text('last_cleanup_error')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'team_id', 'upload_token']); + $table->index(['status', 'expires_at']); + $table->index(['user_id', 'team_id', 'status']); + $table->index(['server_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('terminal_uploaded_files'); + } +}; diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 4e53cd80e4..9a92487407 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -93,6 +93,11 @@ ]) }}"> Terminal + + Upload + @endcan @can('update', $server) + +
+
+

Upload

+
Upload a file to this server or one of its running containers.
+
+ + @if (! $server->isTerminalEnabled()) +
Terminal access is disabled on this server.
+ @elseif (! $server->isFunctional()) +
Server is not functional. Validate it before uploading files.
+ @else + + @endif +
+ diff --git a/resources/views/livewire/terminal/file-import.blade.php b/resources/views/livewire/terminal/file-import.blade.php index 627d173ca9..e29c634fc0 100644 --- a/resources/views/livewire/terminal/file-import.blade.php +++ b/resources/views/livewire/terminal/file-import.blade.php @@ -1,62 +1,186 @@ -
+
-
- Upload a file that will be temporarily stored and accessible in your selected server or container. - Perfect for importing SQL dumps, configuration files, or any other data. + if (file) { + await this.uploadFile(file); + } + }, + async handleDrop(event) { + this.isDragging = false; + const [file] = event.dataTransfer.files; + + if (file) { + await this.uploadFile(file); + } + }, + async uploadFile(file) { + await $wire.resetPendingUpload(); + + this.error = false; + this.filePath = null; + this.filename = file.name; + this.filesize = this.formatFileSize(file.size); + this.hasPendingUpload = false; + this.isUploading = true; + this.progress = 0; + + if (file.size > this.maxFileSize) { + this.error = true; + this.isUploading = false; + await $wire.handleUploadError('File is too large. Maximum size is 10 GB.'); + + return; + } + + const uploadToken = this.generateUploadToken(); + const totalChunks = Math.max(1, Math.ceil(file.size / this.chunkSize)); + + try { + for (let index = 0; index < totalChunks; index++) { + const start = index * this.chunkSize; + const end = Math.min(file.size, start + this.chunkSize); + const chunk = file.slice(start, end); + const formData = new FormData(); + + formData.append('file', chunk, file.name); + formData.append('resumableChunkNumber', String(index + 1)); + formData.append('resumableTotalChunks', String(totalChunks)); + formData.append('resumableChunkSize', String(this.chunkSize)); + formData.append('resumableCurrentChunkSize', String(chunk.size)); + formData.append('resumableTotalSize', String(file.size)); + formData.append('resumableType', file.type || 'application/octet-stream'); + formData.append('resumableIdentifier', uploadToken); + formData.append('resumableFilename', file.name); + formData.append('resumableRelativePath', file.name); + + const response = await fetch(this.uploadUrl, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + credentials: 'same-origin', + body: formData, + }); + + let data = {}; + + try { + data = await response.json(); + } catch { + data = {}; + } + + if (! response.ok || data.error) { + throw new Error(data.error || 'File upload failed.'); + } + + this.progress = typeof data.done === 'number' + ? Math.round(data.done) + : Math.round(((index + 1) / totalChunks) * 100); + + if (data.file_uuid) { + await $wire.registerUploadedFile( + data.file_uuid, + data.original_name || file.name, + Number(data.size || file.size), + ); + } + } + } catch (error) { + this.error = true; + this.isUploading = false; + this.progress = 0; + this.hasPendingUpload = false; + await $wire.resetPendingUpload(); + await $wire.handleUploadError(error.message || 'File upload failed.'); + + return; + } + + this.isUploading = false; + }, + formatFileSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + const precision = size >= 10 || unitIndex === 0 ? 0 : 2; + + return `${Number(size.toFixed(precision))} ${units[unitIndex]}`; + }, + generateUploadToken() { + const token = window.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + return token.replace(/[^a-zA-Z0-9_-]/g, ''); + }, + }" + @path-copied.window="navigator.clipboard.writeText($event.detail.path)"> + + + Uploaded files are stored temporarily and will be automatically deleted after the expiration time. + Do not upload sensitive files without encryption. Always verify file permissions after upload. + + +
+
+ + + @foreach ($servers as $server) + + @foreach ($containers as $container) + @if ($container['server_uuid'] === $server['uuid']) + + @endif + @endforeach + @endforeach +
-
- -
- - @if ($targetName) -
-
- Target: {{ $targetName }} -
-
- @endif - - -
- +
+ @foreach ($this->expirationOptions as $minutes => $label) @endforeach -
- File will be automatically deleted after this time for security. -
- - -
-

Upload File

-
- -
+
+

Upload

+
+ +
- +
- -
-
Uploading: %
+
+
Uploading: %
- -
-

File Uploaded

+
+

File Uploaded to Temporary Storage

Filename:
Size:
- - Generate File Path & Copy to Target + + Finalize Upload
- -
+

File Ready!

File Path: -
- +
+ @@ -118,20 +237,15 @@
Expires in: {{ $expirationMinutes }} minutes
-
+
Copy the file path above and use it in your terminal commands. The file will be automatically deleted after expiration.
- - - Uploaded files are stored temporarily and will be automatically deleted after the expiration time. - Do not upload sensitive files without encryption. Always verify file permissions after upload. - - + @if (count($this->uploadedFiles) > 0)

Previously Uploaded Files

@@ -149,14 +263,14 @@ @foreach ($this->uploadedFiles as $file) - +
{{ $file['display_name'] }}
{{ $file['filename'] }}
{{ $file['server_name'] }}
- @if($file['container_uuid']) + @if ($file['container_uuid'])
Container: {{ substr($file['container_uuid'], 0, 12) }}
@endif @@ -167,12 +281,12 @@ {{ \Carbon\Carbon::createFromTimestamp($file['uploaded_at'])->diffForHumans() }} - @if($file['expires_at']) + @if ($file['expires_at']) @php $expiresAt = \Carbon\Carbon::createFromTimestamp($file['expires_at']); $now = \Carbon\Carbon::now(); @endphp - @if($now->gt($expiresAt)) + @if ($now->gt($expiresAt)) Expired @else {{ $expiresAt->diffForHumans() }} @@ -184,19 +298,32 @@
+ title="Copy remote file path" + wire:click="copyPath('{{ $file['uuid'] }}')"> Copy Path - - Delete - + + + + Delete + + +
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 98dc6c3918..6b45c1b184 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -1,79 +1,94 @@
Terminal | Coolify - + +

Terminal

-
+ @if ($servers->count() > 0) + Import File + @endif +
+
Execute commands on your servers and containers without leaving the browser.
-
-
- @if ($isLoadingContainers) -
- +
+
+ @if ($isLoadingContainers) +
+ +
+ @else + @if ($servers->count() > 0) +
+
+ + + @foreach ($servers as $server) + + @foreach ($containers as $container) + @if ($container['server_uuid'] == $server->uuid) + + @endif + @endforeach + @endforeach + + Connect +
@else - @if ($servers->count() > 0) -
-
- - - @foreach ($servers as $server) - - @foreach ($containers as $container) - @if ($container['server_uuid'] == $server->uuid) - - @endif - @endforeach - @endforeach - - Connect -
- @if ($selected_uuid !== 'default') - Import File - @endif -
- @else -
No servers with terminal access found.
- @endif +
No servers with terminal access found.
@endif - -
+ @endif + +
- @if ($showImportModal && $selected_uuid !== 'default') -
-
-
+ @if ($showImportModal) +
+
+
-
-

Import File to Terminal

- -
-
- @php - $targetName = $this->getTargetName(); - $serverUuid = $this->getServerUuid(); - @endphp - +
+
+

Import File

+
+ Upload a file that will be temporarily stored and accessible in your selected server or container. + Perfect for importing SQL dumps, configuration files, or any other data.
+ +
+
+ @php + $initialImportTarget = $selected_uuid !== 'default' ? $selected_uuid : 'default'; + $importServers = $servers->map(fn ($server) => [ + 'uuid' => $server->uuid, + 'name' => $server->name, + ])->values()->all(); + $importContainers = collect($containers)->map(fn ($container) => [ + 'uuid' => $container['uuid'], + 'name' => $container['name'], + 'server_uuid' => $container['server_uuid'], + ])->values()->all(); + @endphp + +
- @endif -
\ No newline at end of file +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index b12fb69887..183a8ee4ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,7 +33,6 @@ use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups; use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\Shared\ExecuteContainerCommand; use App\Livewire\Project\Shared\Logs; -use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; use App\Livewire\Security\CloudInitScripts; @@ -60,6 +59,7 @@ use App\Livewire\Server\Security\TerminalAccess; use App\Livewire\Server\Sentinel as ServerSentinel; use App\Livewire\Server\Show as ServerShow; use App\Livewire\Server\Swarm as ServerSwarm; +use App\Livewire\Server\Upload as ServerUpload; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; @@ -291,6 +291,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/proxy', ProxyShow::class)->name('server.proxy'); Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); + Route::get('/upload', ServerUpload::class)->name('server.upload')->middleware('can.access.terminal'); Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); @@ -323,7 +324,7 @@ Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () { Route::post('/upload/backup/{databaseUuid}', [UploadController::class, 'upload'])->name('upload.backup'); - Route::post('/upload/terminal', [UploadController::class, 'uploadTerminalFile'])->name('upload.terminal'); + Route::post('/upload/terminal', [UploadController::class, 'uploadTerminalFile'])->middleware('can.access.terminal')->name('upload.terminal'); Route::get('/download/backup/{executionId}', function () { try { $user = auth()->user(); diff --git a/tests/Feature/CleanupTerminalUploadsCommandTest.php b/tests/Feature/CleanupTerminalUploadsCommandTest.php new file mode 100644 index 0000000000..7108296bc0 --- /dev/null +++ b/tests/Feature/CleanupTerminalUploadsCommandTest.php @@ -0,0 +1,76 @@ +create(); + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + + $expiredDirectory = storage_path('app/terminal-uploads/user_'.$user->id.'_expired-token'); + $pendingDirectory = storage_path('app/terminal-uploads-pending/user_'.$user->id.'/pending-token'); + + mkdir($expiredDirectory, 0755, true); + mkdir($pendingDirectory, 0755, true); + + $expiredPath = $expiredDirectory.'/expired-file.txt'; + $pendingPath = $pendingDirectory.'/pending-file.txt'; + + file_put_contents($expiredPath, 'expired'); + file_put_contents($pendingPath, 'pending'); + + $expiredUpload = TerminalUploadedFile::query()->create([ + 'user_id' => $user->id, + 'team_id' => $team->id, + 'upload_token' => 'expired-token', + 'original_name' => 'expired.txt', + 'stored_filename' => 'expired-file.txt', + 'mime_type' => 'text-plain', + 'size_bytes' => 7, + 'local_path' => $expiredPath, + 'server_path' => null, + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Active, + 'uploaded_at' => now()->subHours(2), + 'expires_at' => now()->subMinute(), + 'finalized_at' => now()->subHour(), + ]); + + $pendingUpload = TerminalUploadedFile::query()->create([ + 'user_id' => $user->id, + 'team_id' => $team->id, + 'upload_token' => 'pending-token', + 'original_name' => 'pending.txt', + 'stored_filename' => 'pending-file.txt', + 'mime_type' => 'text-plain', + 'size_bytes' => 7, + 'local_path' => $pendingPath, + 'server_path' => null, + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Pending, + 'uploaded_at' => now()->subHours(4), + 'expires_at' => null, + 'finalized_at' => null, + ]); + + $this->artisan('cleanup:terminal-uploads', ['--pending-hours' => 1]) + ->expectsOutput('Cleaned 1 expired terminal uploads.') + ->expectsOutput('Cleaned 1 stale pending terminal uploads.') + ->assertSuccessful(); + + $expiredUpload->refresh(); + $pendingUpload->refresh(); + + expect(file_exists($expiredPath))->toBeFalse() + ->and(file_exists($pendingPath))->toBeFalse() + ->and($expiredUpload->status)->toBe(TerminalUploadedFileStatus::Deleted) + ->and($pendingUpload->status)->toBe(TerminalUploadedFileStatus::Deleted) + ->and($expiredUpload->deleted_at)->not->toBeNull() + ->and($pendingUpload->deleted_at)->not->toBeNull(); +}); diff --git a/tests/Feature/ServerUploadPageTest.php b/tests/Feature/ServerUploadPageTest.php new file mode 100644 index 0000000000..4cc38b6307 --- /dev/null +++ b/tests/Feature/ServerUploadPageTest.php @@ -0,0 +1,102 @@ +create([ + 'id' => 0, + 'is_registration_enabled' => true, + ]); + }); + + $this->team = Team::factory()->create([ + 'show_boarding' => false, + ]); + + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->privateKey = PrivateKey::create([ + 'team_id' => $this->team->id, + 'name' => 'Test Key', + 'private_key' => <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY, + ]); + + $this->server = Server::factory()->create([ + 'name' => 'Upload Target Server', + 'ip' => 'coolify-upload-page-host', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'is_terminal_enabled' => true, + ]); +}); + +it('shows the upload link on the server page for owners with terminal access', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $response = $this->get(route('server.show', [ + 'server_uuid' => $this->server->uuid, + ])); + + $response->assertSuccessful(); + $response->assertSee('Upload'); + $response->assertSee(route('server.upload', [ + 'server_uuid' => $this->server->uuid, + ]), false); +}); + +it('renders the dedicated server upload page with the terminal upload component preselected to the current server', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $response = $this->get(route('server.upload', [ + 'server_uuid' => $this->server->uuid, + ])); + + $response->assertSuccessful(); + $response->assertSeeLivewire(ServerUpload::class); + $response->assertSeeLivewire(FileImport::class); + $response->assertSee('Upload a file to this server or one of its running containers.'); + $response->assertSee('Upload Target Server'); + + Livewire::test(ServerUpload::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('server.uuid', $this->server->uuid) + ->assertSet('servers.0.uuid', $this->server->uuid); +}); + +it('forbids server upload pages for team members without terminal access', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $this->get(route('server.upload', [ + 'server_uuid' => $this->server->uuid, + ]))->assertForbidden(); +}); diff --git a/tests/Feature/TerminalFileImportSecurityTest.php b/tests/Feature/TerminalFileImportSecurityTest.php new file mode 100644 index 0000000000..172e7cb06e --- /dev/null +++ b/tests/Feature/TerminalFileImportSecurityTest.php @@ -0,0 +1,138 @@ +create(['id' => 0]); + }); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); +}); + +it('rejects copying terminal upload paths outside the current team', function () { + $otherTeam = Team::factory()->create(); + $otherOwner = User::factory()->create(); + $otherOwner->teams()->attach($otherTeam, ['role' => 'owner']); + + $terminalUploadedFile = TerminalUploadedFile::query()->create([ + 'user_id' => $otherOwner->id, + 'team_id' => $otherTeam->id, + 'upload_token' => 'foreign-upload-token', + 'original_name' => 'foreign.txt', + 'stored_filename' => 'stored-foreign.txt', + 'mime_type' => 'text-plain', + 'size_bytes' => 128, + 'local_path' => storage_path('app/terminal-uploads/user_999_foreign/stored-foreign.txt'), + 'server_path' => '/tmp/stored-foreign.txt', + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Active, + 'uploaded_at' => now(), + 'expires_at' => now()->addHour(), + 'finalized_at' => now(), + ]); + + Livewire::test(FileImport::class, [ + 'selectedUuid' => 'server-uuid', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server'], + ], + 'containers' => [], + ]) + ->call('copyPath', $terminalUploadedFile->uuid) + ->assertDispatched('error'); +}); + +it('rejects preparing terminal upload deletions outside the current team', function () { + $otherTeam = Team::factory()->create(); + $otherOwner = User::factory()->create(); + $otherOwner->teams()->attach($otherTeam, ['role' => 'owner']); + + $terminalUploadedFile = TerminalUploadedFile::query()->create([ + 'user_id' => $otherOwner->id, + 'team_id' => $otherTeam->id, + 'upload_token' => 'foreign-prepare-delete-token', + 'original_name' => 'foreign.txt', + 'stored_filename' => 'stored-foreign.txt', + 'mime_type' => 'text-plain', + 'size_bytes' => 128, + 'local_path' => storage_path('app/terminal-uploads/user_999_foreign/stored-foreign.txt'), + 'server_path' => '/tmp/stored-foreign.txt', + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Active, + 'uploaded_at' => now(), + 'expires_at' => now()->addHour(), + 'finalized_at' => now(), + ]); + + Livewire::test(FileImport::class, [ + 'selectedUuid' => 'server-uuid', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server'], + ], + 'containers' => [], + ]) + ->call('prepareFileDeletion', $terminalUploadedFile->uuid) + ->assertSet('pendingDeleteFileUuid', null) + ->assertDispatched('error'); +}); + +it('rejects deleting terminal uploads outside the current team', function () { + $otherTeam = Team::factory()->create(); + $otherOwner = User::factory()->create(); + $otherOwner->teams()->attach($otherTeam, ['role' => 'owner']); + + $terminalUploadedFile = TerminalUploadedFile::query()->create([ + 'user_id' => $otherOwner->id, + 'team_id' => $otherTeam->id, + 'upload_token' => 'foreign-delete-token', + 'original_name' => 'foreign.txt', + 'stored_filename' => 'stored-foreign.txt', + 'mime_type' => 'text-plain', + 'size_bytes' => 128, + 'local_path' => storage_path('app/terminal-uploads/user_999_foreign/stored-foreign.txt'), + 'server_path' => '/tmp/stored-foreign.txt', + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Active, + 'uploaded_at' => now(), + 'expires_at' => now()->addHour(), + 'finalized_at' => now(), + ]); + + Livewire::test(FileImport::class, [ + 'selectedUuid' => 'server-uuid', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server'], + ], + 'containers' => [], + ]) + ->call('deleteFile', $terminalUploadedFile->uuid) + ->assertDispatched('error'); +}); + +it('requires a visible terminal upload before confirming deletion', function () { + Livewire::test(FileImport::class, [ + 'selectedUuid' => 'server-uuid', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server'], + ], + 'containers' => [], + ]) + ->call('confirmDeleteFile') + ->assertSet('pendingDeleteFileUuid', null) + ->assertDispatched('error', 'File not found.'); +}); diff --git a/tests/Feature/TerminalImportModalSelectionTest.php b/tests/Feature/TerminalImportModalSelectionTest.php new file mode 100644 index 0000000000..3c1251a81f --- /dev/null +++ b/tests/Feature/TerminalImportModalSelectionTest.php @@ -0,0 +1,152 @@ +create(['id' => 0]); + }); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); +}); + +it('shows the import file button even when no terminal target is selected', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY, + 'team_id' => $this->team->id, + ]); + + $server = Server::factory()->create([ + 'name' => 'Terminal Server', + 'ip' => 'coolify-terminal-test-host', + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + $server->settings()->update([ + 'is_reachable' => true, + 'is_terminal_enabled' => true, + ]); + + Livewire::test(TerminalIndex::class) + ->call('loadContainers') + ->assertSet('selected_uuid', 'default') + ->assertSee('Import File'); +}); + +it('requires selecting a server or container before finalizing an uploaded file', function () { + Livewire::test(FileImport::class, [ + 'selectedUuid' => 'default', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server One'], + ], + 'containers' => [ + ['uuid' => 'container-uuid', 'name' => 'App Container', 'server_uuid' => 'server-uuid'], + ], + ]) + ->set('hasPendingUpload', true) + ->set('filename', 'dump.sql') + ->call('generateFilePath') + ->assertDispatched('error', 'Please select a server or container.'); +}); + +it('renders the shared modal confirmation for deleting uploaded files', function () { + TerminalUploadedFile::query()->create([ + 'user_id' => $this->owner->id, + 'team_id' => $this->team->id, + 'upload_token' => 'render-delete-modal-token', + 'original_name' => 'database-dump.sql', + 'stored_filename' => 'stored-database-dump.sql', + 'mime_type' => 'application-sql', + 'size_bytes' => 1024, + 'local_path' => storage_path('app/terminal-uploads/user_1_render-delete-modal/stored-database-dump.sql'), + 'server_path' => '/tmp/stored-database-dump.sql', + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Active, + 'uploaded_at' => now(), + 'expires_at' => now()->addHour(), + 'finalized_at' => now(), + ]); + + $component = Livewire::test(FileImport::class, [ + 'selectedUuid' => 'server-uuid', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server One'], + ], + 'containers' => [], + ]) + ->assertSee('Confirm Terminal File Deletion?') + ->assertSee('Please confirm the deletion by entering the uploaded file name below'); + + expect($component->html()) + ->toContain('confirmDeleteFile') + ->not->toContain('wire:confirm='); +}); + +it('prepares and confirms terminal upload deletion through the shared modal flow', function () { + $terminalUploadedFile = TerminalUploadedFile::query()->create([ + 'user_id' => $this->owner->id, + 'team_id' => $this->team->id, + 'upload_token' => 'confirm-delete-modal-token', + 'original_name' => 'database-dump.sql', + 'stored_filename' => 'stored-database-dump.sql', + 'mime_type' => 'application-sql', + 'size_bytes' => 1024, + 'local_path' => storage_path('app/terminal-uploads/user_1_confirm-delete-modal/stored-database-dump.sql'), + 'server_path' => '/tmp/stored-database-dump.sql', + 'container_path' => null, + 'status' => TerminalUploadedFileStatus::Active, + 'uploaded_at' => now(), + 'expires_at' => now()->addHour(), + 'finalized_at' => now(), + ]); + + $deleteTerminalUploadedFile = Mockery::mock(DeleteTerminalUploadedFile::class); + $deleteTerminalUploadedFile->shouldReceive('__invoke') + ->once() + ->withArgs(function (TerminalUploadedFile $file) use ($terminalUploadedFile): bool { + return $file->is($terminalUploadedFile); + }); + + app()->instance(DeleteTerminalUploadedFile::class, $deleteTerminalUploadedFile); + + Livewire::test(FileImport::class, [ + 'selectedUuid' => 'server-uuid', + 'servers' => [ + ['uuid' => 'server-uuid', 'name' => 'Server One'], + ], + 'containers' => [], + ]) + ->call('prepareFileDeletion', $terminalUploadedFile->uuid) + ->assertSet('pendingDeleteFileUuid', $terminalUploadedFile->uuid) + ->call('confirmDeleteFile') + ->assertSet('pendingDeleteFileUuid', null) + ->assertDispatched('success', 'File deleted successfully!'); +}); diff --git a/tests/Feature/TerminalUploadRouteTest.php b/tests/Feature/TerminalUploadRouteTest.php new file mode 100644 index 0000000000..91064ad5ac --- /dev/null +++ b/tests/Feature/TerminalUploadRouteTest.php @@ -0,0 +1,85 @@ +team = Team::factory()->create(); +}); + +it('denies unauthenticated terminal uploads', function () { + $this->postJson(route('upload.terminal')) + ->assertStatus(401); +}); + +it('denies terminal uploads for team members without terminal access', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $this->post(route('upload.terminal'), [ + 'file' => UploadedFile::fake()->create('notes.txt', 1), + ], [ + 'Accept' => 'application/json', + ])->assertForbidden(); +}); + +it('stores a pending terminal upload record for team owners', function () { + $owner = User::factory()->create(); + $owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + $file = UploadedFile::fake()->create('terminal-import.sql', 32); + + $response = $this->post(route('upload.terminal'), [ + 'file' => $file, + 'resumableChunkNumber' => 1, + 'resumableTotalChunks' => 1, + 'resumableChunkSize' => $file->getSize(), + 'resumableCurrentChunkSize' => $file->getSize(), + 'resumableTotalSize' => $file->getSize(), + 'resumableType' => $file->getMimeType(), + 'resumableIdentifier' => 'terminal_upload_token', + 'resumableFilename' => 'terminal-import.sql', + 'resumableRelativePath' => 'terminal-import.sql', + ], [ + 'Accept' => 'application/json', + ]); + + $response + ->assertOk() + ->assertJson([ + 'done' => 100, + 'status' => true, + 'upload_token' => 'terminal_upload_token', + 'original_name' => 'terminal-import.sql', + ]); + + $storedFilename = $response->json('stored_filename'); + $fileUuid = $response->json('file_uuid'); + + expect($storedFilename) + ->not->toBeEmpty() + ->and($storedFilename)->toEndWith('.sql'); + + $terminalUploadedFile = TerminalUploadedFile::query()->where('uuid', $fileUuid)->first(); + + expect($terminalUploadedFile) + ->not->toBeNull() + ->and($terminalUploadedFile->user_id)->toBe($owner->id) + ->and($terminalUploadedFile->team_id)->toBe($this->team->id) + ->and($terminalUploadedFile->upload_token)->toBe('terminal_upload_token') + ->and($terminalUploadedFile->status)->toBe(TerminalUploadedFileStatus::Pending) + ->and($terminalUploadedFile->original_name)->toBe('terminal-import.sql') + ->and(file_exists($terminalUploadedFile->local_path))->toBeTrue(); +}); diff --git a/tests/Unit/TerminalFileHelperTest.php b/tests/Unit/TerminalFileHelperTest.php new file mode 100644 index 0000000000..6228c62bb4 --- /dev/null +++ b/tests/Unit/TerminalFileHelperTest.php @@ -0,0 +1,21 @@ +not->toBeNull() + ->and($metadata['server_id'])->toBe(42) + ->and($metadata['extension'])->toBe('sql') + ->and($metadata['container_uuid'])->toBe('container-uu'); +}); + +it('rejects malformed terminal filename extensions', function () { + $filename = '1700000000_1700003600_42_nocontainer_demo_a1b2c3d4.bad-ext'; + + expect(TerminalFileHelper::parseFilename($filename))->toBeNull(); +});