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(); +});