From 72020a7363c1e587f251766903d9f27b0a924847 Mon Sep 17 00:00:00 2001 From: Ahliman HUSEYNOV Date: Wed, 12 Nov 2025 21:30:11 +0100 Subject: [PATCH 1/6] feat: Add terminal file upload functionality --- app/Http/Controllers/UploadController.php | 57 +++++- app/Jobs/CleanupExpiredTerminalFilesJob.php | 80 ++++++++ app/Livewire/Terminal/FileImport.php | 180 ++++++++++++++++++ app/Livewire/Terminal/Index.php | 54 ++++++ .../livewire/terminal/file-import.blade.php | 124 ++++++++++++ .../views/livewire/terminal/index.blade.php | 70 +++++-- routes/web.php | 1 + 7 files changed, 544 insertions(+), 22 deletions(-) create mode 100644 app/Jobs/CleanupExpiredTerminalFilesJob.php create mode 100644 app/Livewire/Terminal/FileImport.php create mode 100644 resources/views/livewire/terminal/file-import.blade.php diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 4d34a1000..3d8c1076f 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Facades\Auth; use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException; use Pion\Laravel\ChunkUpload\Handler\HandlerFactory; use Pion\Laravel\ChunkUpload\Receiver\FileReceiver; @@ -20,7 +21,7 @@ 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(); @@ -69,13 +70,57 @@ class UploadController extends BaseController ]); } - protected function createFilename(UploadedFile $file) + public function uploadTerminalFile(Request $request) { - $extension = $file->getClientOriginalExtension(); - $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension + // Security: Verify user has permission to upload terminal files + if (! Auth::check()) { + return response()->json(['error' => 'Unauthorized'], 401); + } - $filename .= '_'.md5(time()).'.'.$extension; + // 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); + } - return $filename; + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); + + if ($receiver->isUploaded() === false) { + throw new UploadMissingFileException(); + } + + $save = $receiver->receive(); + + if ($save->isFinished()) { + return $this->saveTerminalFile($save->getFile()); + } + + $handler = $save->handler(); + + return response()->json([ + 'done' => $handler->getPercentageDone(), + 'status' => true, + ]); + } + + protected function saveTerminalFile(UploadedFile $file) + { + $mime = str_replace('/', '-', $file->getMimeType()); + $filePath = 'terminal-uploads/temp'; + $finalPath = storage_path('app/'.$filePath); + + // Create directory if it doesn't exist + if (! is_dir($finalPath)) { + mkdir($finalPath, 0755, true); + } + + // Use original filename with timestamp to avoid conflicts + $filename = time().'_'.$file->getClientOriginalName(); + $file->move($finalPath, $filename); + + return response()->json([ + 'mime_type' => $mime, + 'filename' => $filename, + ]); } } diff --git a/app/Jobs/CleanupExpiredTerminalFilesJob.php b/app/Jobs/CleanupExpiredTerminalFilesJob.php new file mode 100644 index 000000000..b0b1558ca --- /dev/null +++ b/app/Jobs/CleanupExpiredTerminalFilesJob.php @@ -0,0 +1,80 @@ +localPath)) { + unlink($this->localPath); + Log::info("Cleaned up local terminal file: {$this->localPath}"); + } + + // Delete file from server + $server = Server::find($this->serverId); + if ($server) { + // Remove from server + $result = instant_remote_process([ + "rm -f {$this->serverPath}" + ], $server, throwError: false); + + if ($result) { + Log::info("Cleaned up server terminal file: {$this->serverPath}"); + } + + // If container was specified, remove from container as well + if ($this->containerUuid) { + $containerPath = "/tmp/{$this->filename}"; + instant_remote_process([ + "docker exec {$this->containerUuid} rm -f {$containerPath} 2>/dev/null || true" + ], $server, throwError: false); + + Log::info("Cleaned up container terminal file: {$containerPath}"); + } + } + + // Clean up empty parent directory + $parentDir = dirname($this->localPath); + if (is_dir($parentDir) && count(scandir($parentDir)) === 2) { // Only . and .. + rmdir($parentDir); + } + } catch (\Throwable $e) { + Log::error("Failed to cleanup terminal file: {$e->getMessage()}", [ + 'localPath' => $this->localPath, + 'serverPath' => $this->serverPath, + 'serverId' => $this->serverId, + ]); + + // Don't fail the job, just log the error + // Files will eventually be cleaned up by manual cleanup or system maintenance + } + } +} diff --git a/app/Livewire/Terminal/FileImport.php b/app/Livewire/Terminal/FileImport.php new file mode 100644 index 000000000..f8c0cd39f --- /dev/null +++ b/app/Livewire/Terminal/FileImport.php @@ -0,0 +1,180 @@ +validate([ + 'uploadedFile' => 'required|file|max:10485760', // 10GB in KB + ]); + + $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!'); + } + + #[Computed] + public function expirationOptions() + { + return [ + 15 => '15 minutes', + 30 => '30 minutes', + 60 => '1 hour', + 120 => '2 hours', + 240 => '4 hours', + 480 => '8 hours', + 1440 => '24 hours', + ]; + } + + 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() + { + if (empty($this->filename)) { + $this->dispatch('error', 'No file uploaded yet.'); + + return; + } + + try { + // Determine server UUID + $serverUuid = $this->selectedServerUuid ?? $this->selectedUuid; + $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->first(); + + if (! $server) { + $this->dispatch('error', 'Server not found.'); + + return; + } + + $isContainer = ($this->selectedServerUuid && $this->selectedUuid !== $this->selectedServerUuid); + + // Generate unique file identifier + $uploadId = uniqid('terminal_', true); + $sanitizedFilename = basename($this->filename); + $storageDir = "terminal-uploads/{$uploadId}"; + $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)) { + mkdir($storagePath, 0755, true); + } + + $finalPath = "{$storagePath}/{$sanitizedFilename}"; + $this->uploadedFile->storeAs($storageDir, $sanitizedFilename); + + // Copy file to server's temporary directory + $serverTmpPath = "/tmp/coolify_import_{$uploadId}_{$sanitizedFilename}"; + instant_scp($finalPath, $serverTmpPath, $server); + + // If it's a container, copy to container + if ($isContainer) { + $containerPath = "/tmp/{$sanitizedFilename}"; + + instant_remote_process([ + "docker cp {$serverTmpPath} {$this->selectedUuid}:{$containerPath}", + ], $server); + + $this->filePath = $containerPath; + } else { + $this->filePath = $serverTmpPath; + } + + // Schedule cleanup job + CleanupExpiredTerminalFilesJob::dispatch( + $finalPath, + $serverTmpPath, + $server->id, + $isContainer ? $this->selectedUuid : null, + $sanitizedFilename + )->delay(now()->addMinutes($this->expirationMinutes)); + + $this->dispatch('success', "File ready! Path: {$this->filePath}"); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.terminal.file-import'); + } + + /** + * Workaround for Livewire toJSON serialization issue + * This prevents "Public method [toJSON] not found" errors + */ + public function toJSON() + { + return json_encode([ + 'selectedUuid' => $this->selectedUuid, + 'targetName' => $this->targetName, + 'selectedServerUuid' => $this->selectedServerUuid, + ]); + } +} diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 6bb4c5e90..5caa8e5af 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -16,6 +16,8 @@ class Index extends Component public bool $isLoadingContainers = true; + public bool $showImportModal = false; + public function mount() { $this->servers = Server::isReachable()->get()->filter(function ($server) { @@ -84,6 +86,58 @@ class Index extends Component ); } + 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() + { + $this->showImportModal = true; + } + + public function closeImportModal() + { + $this->showImportModal = false; + } + public function render() { return view('livewire.terminal.index'); diff --git a/resources/views/livewire/terminal/file-import.blade.php b/resources/views/livewire/terminal/file-import.blade.php new file mode 100644 index 000000000..78a4646b3 --- /dev/null +++ b/resources/views/livewire/terminal/file-import.blade.php @@ -0,0 +1,124 @@ +
+ +
+

Import File for Terminal

+
+ 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 ($targetName) +
+
+ Target: {{ $targetName }} +
+
+ @endif + + +
+ + @foreach ($this->expirationOptions as $minutes => $label) + + @endforeach + +
+ File will be automatically deleted after this time for security. +
+
+ + +
+

Upload File

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

File Uploaded

+
+
Filename:
+
Size:
+
+ + Generate File Path & Copy to Target + +
+ + +
+

File Ready!

+
+
+ File Path: + +
+
+ Expires in: {{ $expirationMinutes }} minutes +
+
+ Copy the file path above and use it in your terminal commands. + The file will be automatically deleted after expiration. +
+
+
+ + +
+ + + +
+
Security Notice
+
+ 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. +
+
+
+
+
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 56a8acae7..98dc6c391 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -15,27 +15,65 @@ @else @if ($servers->count() > 0) -
- - - @foreach ($servers as $server) - - @foreach ($containers as $container) - @if ($container['server_uuid'] == $server->uuid) - - @endif +
+ + + + @foreach ($servers as $server) + + @foreach ($containers as $container) + @if ($container['server_uuid'] == $server->uuid) + + @endif + @endforeach @endforeach - @endforeach - - Connect - + + Connect + + @if ($selected_uuid !== 'default') + Import File + @endif +
@else
No servers with terminal access found.
@endif @endif + + @if ($showImportModal && $selected_uuid !== 'default') +
+
+
+
+
+

Import File to Terminal

+ +
+
+ @php + $targetName = $this->getTargetName(); + $serverUuid = $this->getServerUuid(); + @endphp + +
+
+
+ @endif \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 703f80ab5..93ed07550 100644 --- a/routes/web.php +++ b/routes/web.php @@ -295,6 +295,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::get('/download/backup/{executionId}', function () { try { $user = auth()->user(); From 903621dbaa9bccd0d5fa8e8ecc895608f50ddbdb Mon Sep 17 00:00:00 2001 From: Ahliman HUSEYNOV Date: Thu, 13 Nov 2025 11:21:21 +0100 Subject: [PATCH 2/6] fix(security): Enhance file upload security and cleanup processes for terminal file upload --- app/Http/Controllers/UploadController.php | 24 +++++++++++++++---- app/Jobs/CleanupExpiredTerminalFilesJob.php | 12 ++++++---- app/Livewire/Terminal/FileImport.php | 7 ++++-- .../livewire/terminal/file-import.blade.php | 1 - 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 3d8c1076f..4a531593f 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException; use Pion\Laravel\ChunkUpload\Handler\HandlerFactory; use Pion\Laravel\ChunkUpload\Receiver\FileReceiver; @@ -114,13 +115,28 @@ class UploadController extends BaseController mkdir($finalPath, 0755, true); } - // Use original filename with timestamp to avoid conflicts - $filename = time().'_'.$file->getClientOriginalName(); - $file->move($finalPath, $filename); + // 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) + $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); return response()->json([ 'mime_type' => $mime, - 'filename' => $filename, + 'filename' => $safeFilename, + 'original_name' => $originalName, // Keep original name for reference ]); } } diff --git a/app/Jobs/CleanupExpiredTerminalFilesJob.php b/app/Jobs/CleanupExpiredTerminalFilesJob.php index b0b1558ca..bc392d212 100644 --- a/app/Jobs/CleanupExpiredTerminalFilesJob.php +++ b/app/Jobs/CleanupExpiredTerminalFilesJob.php @@ -41,9 +41,10 @@ class CleanupExpiredTerminalFilesJob implements ShouldQueue // Delete file from server $server = Server::find($this->serverId); if ($server) { - // Remove from server + // Remove from server - escape shell arguments to prevent injection + $escapedServerPath = escapeshellarg($this->serverPath); $result = instant_remote_process([ - "rm -f {$this->serverPath}" + "rm -f {$escapedServerPath}" ], $server, throwError: false); if ($result) { @@ -52,9 +53,12 @@ class CleanupExpiredTerminalFilesJob implements ShouldQueue // If container was specified, remove from container as well if ($this->containerUuid) { - $containerPath = "/tmp/{$this->filename}"; + $escapedContainerUuid = escapeshellarg($this->containerUuid); + $escapedFilename = escapeshellarg($this->filename); + $containerPath = "/tmp/{$this->filename}"; // For logging only + instant_remote_process([ - "docker exec {$this->containerUuid} rm -f {$containerPath} 2>/dev/null || true" + "docker exec {$escapedContainerUuid} rm -f /tmp/{$escapedFilename} 2>/dev/null || true" ], $server, throwError: false); Log::info("Cleaned up container terminal file: {$containerPath}"); diff --git a/app/Livewire/Terminal/FileImport.php b/app/Livewire/Terminal/FileImport.php index f8c0cd39f..fdef11541 100644 --- a/app/Livewire/Terminal/FileImport.php +++ b/app/Livewire/Terminal/FileImport.php @@ -130,14 +130,17 @@ class FileImport extends Component // Copy file to server's temporary directory $serverTmpPath = "/tmp/coolify_import_{$uploadId}_{$sanitizedFilename}"; - instant_scp($finalPath, $serverTmpPath, $server); + $safeServerTmpPath = escapeshellarg($serverTmpPath); + instant_scp($finalPath, $safeServerTmpPath, $server); // If it's a container, copy to container if ($isContainer) { $containerPath = "/tmp/{$sanitizedFilename}"; + $safeContainer = escapeshellarg($this->selectedUuid); + $safeContainerPath = escapeshellarg($containerPath); instant_remote_process([ - "docker cp {$serverTmpPath} {$this->selectedUuid}:{$containerPath}", + "docker cp {$safeServerTmpPath} {$safeContainer}:{$safeContainerPath}", ], $server); $this->filePath = $containerPath; diff --git a/resources/views/livewire/terminal/file-import.blade.php b/resources/views/livewire/terminal/file-import.blade.php index 78a4646b3..fc834e540 100644 --- a/resources/views/livewire/terminal/file-import.blade.php +++ b/resources/views/livewire/terminal/file-import.blade.php @@ -1,7 +1,6 @@
-

Import File for Terminal

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. From da454f4b38964f8ca41c119b599a83c9e3d34981 Mon Sep 17 00:00:00 2001 From: Ahliman HUSEYNOV Date: Thu, 13 Nov 2025 11:21:32 +0100 Subject: [PATCH 3/6] refactor: Replace security notice with a callout component for better readability --- .../livewire/terminal/file-import.blade.php | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/resources/views/livewire/terminal/file-import.blade.php b/resources/views/livewire/terminal/file-import.blade.php index fc834e540..d23d723da 100644 --- a/resources/views/livewire/terminal/file-import.blade.php +++ b/resources/views/livewire/terminal/file-import.blade.php @@ -105,19 +105,9 @@
-
- - - -
-
Security Notice
-
- 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. -
-
-
+ + 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. +
From bba1f47e3323c3fb9239d2f125db3c749c922f50 Mon Sep 17 00:00:00 2001 From: Ahliman HUSEYNOV Date: Thu, 13 Nov 2025 15:47:16 +0100 Subject: [PATCH 4/6] feat(terminal): Add "Previously Uploaded Files" table and improve UI --- app/Helpers/TerminalFileHelper.php | 126 +++++++++++ app/Livewire/Terminal/FileImport.php | 207 +++++++++++++++++- .../livewire/terminal/file-import.blade.php | 109 ++++++++- 3 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 app/Helpers/TerminalFileHelper.php diff --git a/app/Helpers/TerminalFileHelper.php b/app/Helpers/TerminalFileHelper.php new file mode 100644 index 000000000..849c559ed --- /dev/null +++ b/app/Helpers/TerminalFileHelper.php @@ -0,0 +1,126 @@ + (int) $matches[1], + 'expires_at' => (int) $matches[2], + 'server_id' => (int) $matches[3], + 'container_uuid' => $matches[4] === 'nocontainer' ? null : $matches[4], + 'original_name' => str_replace('-', ' ', $matches[5]), + 'hash' => $matches[6], + 'extension' => $matches[7] ?? null, + ]; + } + + /** + * Generate server path for the uploaded file + */ + public static function generateServerPath(string $filename): string + { + // Just use the filename directly - it already contains all metadata + return "/tmp/{$filename}"; + } + + /** + * Generate container path for the uploaded file + */ + public static function generateContainerPath(string $filename): string + { + // Use same filename in container + return "/tmp/{$filename}"; + } + + /** + * Check if file is expired + */ + public static function isExpired(string $filename): bool + { + $metadata = self::parseFilename($filename); + + if (!$metadata) { + return false; + } + + return time() > $metadata['expires_at']; + } + + /** + * Get display name from filename + */ + public static function getDisplayName(string $filename): string + { + $metadata = self::parseFilename($filename); + + if (!$metadata) { + return $filename; + } + + $displayName = ucwords($metadata['original_name']); + + if ($metadata['extension']) { + $displayName .= '.' . $metadata['extension']; + } + + return $displayName; + } +} diff --git a/app/Livewire/Terminal/FileImport.php b/app/Livewire/Terminal/FileImport.php index fdef11541..5bb03a1cb 100644 --- a/app/Livewire/Terminal/FileImport.php +++ b/app/Livewire/Terminal/FileImport.php @@ -2,10 +2,12 @@ namespace App\Livewire\Terminal; +use App\Helpers\TerminalFileHelper; use App\Jobs\CleanupExpiredTerminalFilesJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Livewire\Attributes\Computed; use Livewire\Component; use Livewire\WithFileUploads; @@ -54,6 +56,7 @@ class FileImport extends Component public function expirationOptions() { return [ + 5 => '5 minutes', 15 => '15 minutes', 30 => '30 minutes', 60 => '1 hour', @@ -64,6 +67,187 @@ class FileImport extends Component ]; } + #[Computed] + public function uploadedFiles() + { + $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; + } + + public function copyPath(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; + } + + // Parse metadata from filename + $metadata = TerminalFileHelper::parseFilename($filename); + + if (!$metadata) { + $this->dispatch('error', 'Invalid file format'); + return; + } + + // Generate server path + $serverPath = TerminalFileHelper::generateServerPath($filename); + + // 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(); @@ -109,8 +293,21 @@ class FileImport extends Component // Generate unique file identifier $uploadId = uniqid('terminal_', true); - $sanitizedFilename = basename($this->filename); - $storageDir = "terminal-uploads/{$uploadId}"; + $originalFilename = basename($this->filename); + $userId = Auth::id(); + + // Calculate expiration timestamp + $expiresAt = now()->addMinutes($this->expirationMinutes)->timestamp; + + // Generate filename with embedded metadata + $sanitizedFilename = TerminalFileHelper::generateFilename( + $originalFilename, + $expiresAt, + $server->id, + $isContainer ? $this->selectedUuid : null + ); + + $storageDir = "terminal-uploads/user_{$userId}_{$uploadId}"; $storagePath = storage_path("app/{$storageDir}"); // Get the uploaded file from Livewire @@ -128,14 +325,14 @@ class FileImport extends Component $finalPath = "{$storagePath}/{$sanitizedFilename}"; $this->uploadedFile->storeAs($storageDir, $sanitizedFilename); - // Copy file to server's temporary directory - $serverTmpPath = "/tmp/coolify_import_{$uploadId}_{$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 ($isContainer) { - $containerPath = "/tmp/{$sanitizedFilename}"; + $containerPath = TerminalFileHelper::generateContainerPath($sanitizedFilename); $safeContainer = escapeshellarg($this->selectedUuid); $safeContainerPath = escapeshellarg($containerPath); diff --git a/resources/views/livewire/terminal/file-import.blade.php b/resources/views/livewire/terminal/file-import.blade.php index d23d723da..5887b6440 100644 --- a/resources/views/livewire/terminal/file-import.blade.php +++ b/resources/views/livewire/terminal/file-import.blade.php @@ -1,4 +1,11 @@ -
+
@@ -51,14 +58,14 @@ >