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