diff --git a/app/Helpers/TerminalFileHelper.php b/app/Helpers/TerminalFileHelper.php new file mode 100644 index 000000000..36190914d --- /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/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 93847589a..e691f8278 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers; 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; @@ -21,7 +23,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(); @@ -73,13 +75,72 @@ class UploadController extends BaseController ]); } - protected function createFilename(UploadedFile $file) + public function uploadTerminalFile(Request $request) { + // 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); + } + + $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); + } + + // Security: Generate safe filename server-side to prevent path traversal + $originalName = $file->getClientOriginalName(); $extension = $file->getClientOriginalExtension(); - $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension - $filename .= '_'.md5(time()).'.'.$extension; + // 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 - return $filename; + // 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' => $safeFilename, + 'original_name' => $originalName, // Keep original name for reference + ]); } } diff --git a/app/Jobs/CleanupExpiredTerminalFilesJob.php b/app/Jobs/CleanupExpiredTerminalFilesJob.php new file mode 100644 index 000000000..706e3e7e0 --- /dev/null +++ b/app/Jobs/CleanupExpiredTerminalFilesJob.php @@ -0,0 +1,87 @@ +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 - escape shell arguments to prevent injection + $escapedServerPath = escapeshellarg($this->serverPath); + $result = instant_remote_process([ + "rm -f {$escapedServerPath}" + ], $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) { + $escapedContainerUuid = escapeshellarg($this->containerUuid); + $escapedFilename = escapeshellarg($this->filename); + $containerPath = "/tmp/{$this->filename}"; // For logging only + + instant_remote_process([ + "docker exec {$escapedContainerUuid} rm -f /tmp/{$escapedFilename} 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)) { + $contents = scandir($parentDir); + if ($contents !== false && count($contents) === 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..5bb03a1cb --- /dev/null +++ b/app/Livewire/Terminal/FileImport.php @@ -0,0 +1,380 @@ +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 [ + 5 => '5 minutes', + 15 => '15 minutes', + 30 => '30 minutes', + 60 => '1 hour', + 120 => '2 hours', + 240 => '4 hours', + 480 => '8 hours', + 1440 => '24 hours', + ]; + } + + #[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(); + + 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); + $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 + 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); + + // 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 = TerminalFileHelper::generateContainerPath($sanitizedFilename); + $safeContainer = escapeshellarg($this->selectedUuid); + $safeContainerPath = escapeshellarg($containerPath); + + instant_remote_process([ + "docker cp {$safeServerTmpPath} {$safeContainer}:{$safeContainerPath}", + ], $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/bootstrap/helpers/filesize.php b/bootstrap/helpers/filesize.php new file mode 100644 index 000000000..09ef3c365 --- /dev/null +++ b/bootstrap/helpers/filesize.php @@ -0,0 +1,25 @@ + + +
+
+ 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. +
+
+
+ + + + 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

+
+ + + + + + + + + + + + + @foreach ($this->uploadedFiles as $file) + + + + + + + + + @endforeach + +
File NameServerSizeUploadedExpiresActions
+
{{ $file['display_name'] }}
+
{{ $file['filename'] }}
+
+
{{ $file['server_name'] }}
+ @if($file['container_uuid']) +
Container: {{ substr($file['container_uuid'], 0, 12) }}
+ @endif +
+ {{ formatBytes($file['size']) }} + + {{ \Carbon\Carbon::createFromTimestamp($file['uploaded_at'])->diffForHumans() }} + + @if($file['expires_at']) + @php + $expiresAt = \Carbon\Carbon::createFromTimestamp($file['expires_at']); + $now = \Carbon\Carbon::now(); + @endphp + @if($now->gt($expiresAt)) + Expired + @else + {{ $expiresAt->diffForHumans() }} + @endif + @else + N/A + @endif + +
+ + Copy Path + + + Delete + +
+
+
+
+ @endif +
+ 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 b6c6c95ce..d2998bab7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -304,6 +304,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();