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 @@ -