authorizeForRequestUser($request, 'viewPanel', $account); $account->load(['node', 'product', 'sites', 'databases']); $stats = $this->getAccountStats($account); return view('hosting.panel.index', [ 'account' => $account, 'stats' => $stats, ]); } public function terminal(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'useTerminal', $account); $account->load(['node']); $path = $this->resolveTerminalPath((string) $request->get('path', '/public_html')); return view('hosting.panel.terminal', [ 'account' => $account, 'currentPath' => $path, 'breadcrumbs' => $this->getBreadcrumbs($path), 'allowedCommands' => [ 'help', 'pwd', 'cd logs', 'ls -la', 'php artisan about', 'git status', 'composer install', 'npm run build', 'clear', ], ]); } public function runTerminalCommand(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'useTerminal', $account); $account->load(['node']); if ($account->status === HostingAccount::STATUS_SUSPENDED) { return response()->json([ 'success' => false, 'error' => 'This hosting account is suspended.', ], 423); } $validated = $request->validate([ 'command' => 'required|string|max:500', 'path' => 'nullable|string|max:255', ]); $command = trim((string) $validated['command']); $path = $this->resolveTerminalPath((string) ($validated['path'] ?? '/public_html')); try { $builtinResult = $this->handleTerminalBuiltin($account, $command, $path); if ($builtinResult !== null) { return response()->json($builtinResult); } $this->assertAllowedTerminalCommand($command); } catch (\RuntimeException $e) { return response()->json([ 'success' => false, 'error' => $e->getMessage(), 'cwd' => $path, ], 422); } $workingDirectory = $this->terminalWorkingDirectory($account, $path); try { $result = $this->runtimeFor($account)->executeTerminalCommand( $account, $command, $workingDirectory ); } catch (\RuntimeException $e) { return response()->json([ 'success' => false, 'error' => $e->getMessage(), 'cwd' => $path, ], 422); } $output = (string) ($result['output'] ?? ''); $truncated = false; if (strlen($output) > 100000) { $output = substr($output, 0, 100000); $truncated = true; } return response()->json([ 'success' => ($result['exit_code'] ?? 1) === 0, 'exit_code' => (int) ($result['exit_code'] ?? 1), 'output' => $output, 'truncated' => $truncated, 'cwd' => $path, ]); } private function assertAllowedTerminalCommand(string $command): void { if ($command === '') { throw new \RuntimeException('Command is required.'); } if (preg_match('/[\r\n]/', $command) === 1) { throw new \RuntimeException('Terminal commands must be a single line.'); } if (preg_match('/(?:\|\||&&|[|;<>`$])/', $command) === 1) { throw new \RuntimeException('Terminal commands cannot use shell chaining, pipes, or redirection.'); } $token = strtok($command, " \t"); if ($token === false || $token === '') { throw new \RuntimeException('Command is required.'); } if (str_contains($token, '..') || str_starts_with($token, '/')) { throw new \RuntimeException('Terminal command executable is not allowed.'); } $executable = basename(ltrim($token, './')); if (! in_array($executable, self::TERMINAL_ALLOWED_EXECUTABLES, true)) { throw new \RuntimeException("`{$executable}` is not available in the panel terminal."); } } private function handleTerminalBuiltin(HostingAccount $account, string $command, string &$path): ?array { if ($command === '') { throw new \RuntimeException('Command is required.'); } if ($command === 'clear') { return [ 'success' => true, 'exit_code' => 0, 'output' => '', 'cwd' => $path, 'clear' => true, 'truncated' => false, ]; } if ($command === 'help') { return [ 'success' => true, 'exit_code' => 0, 'output' => implode("\n", [ 'Built-ins: help, clear, pwd, cd', 'Examples: ls -la, git status, php artisan about, npm run build', 'Scope: commands run inside your hosting account home directory.', 'Restrictions: no pipes, redirects, shell chaining, or multi-line commands.', ]), 'cwd' => $path, 'truncated' => false, ]; } if ($command === 'pwd') { return [ 'success' => true, 'exit_code' => 0, 'output' => $this->terminalWorkingDirectory($account, $path), 'cwd' => $path, 'truncated' => false, ]; } if (preg_match('/^cd(?:\s+(.*))?$/', $command, $matches) === 1) { $targetPath = trim((string) ($matches[1] ?? '')); $nextPath = $targetPath === '' ? '/' : $this->resolveTerminalPath($targetPath, $path); $this->assertTerminalDirectoryExists($account, $nextPath); $path = $nextPath; return [ 'success' => true, 'exit_code' => 0, 'output' => '', 'cwd' => $path, 'truncated' => false, ]; } return null; } // ==================== FILE MANAGER ==================== public function fileManager(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); $path = $request->get('path', '/public_html'); $path = $this->sanitizePath($path); $files = $this->listFiles($account, $path); return view('hosting.panel.files', [ 'account' => $account, 'currentPath' => $path, 'files' => $files, 'breadcrumbs' => $this->getBreadcrumbs($path), ]); } public function fileManagerApi(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); $path = $request->get('path', '/public_html'); $path = $this->sanitizePath($path); $files = $this->listFiles($account, $path); return response()->json([ 'success' => true, 'path' => $path, 'files' => $files, 'breadcrumbs' => $this->getBreadcrumbs($path), ]); } public function readFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); $path = $request->get('path'); if (!$path) { return response()->json(['success' => false, 'error' => 'Path required'], 400); } $path = $this->sanitizePath($path); $fullPath = "/home/{$account->username}{$path}"; $result = $this->runtimeFor($account)->executeCommand($account, "stat -c%s " . escapeshellarg($fullPath) . " 2>/dev/null"); $size = (int) trim($result['output']); if ($size > 1048576) { return response()->json(['success' => false, 'error' => 'File too large to edit (max 1MB)'], 400); } $result = $this->runtimeFor($account)->executeCommand($account, "cat " . escapeshellarg($fullPath)); if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to read file'], 500); } return response()->json([ 'success' => true, 'content' => $result['output'], 'path' => $path, ]); } public function saveFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); $validated = $request->validate([ 'path' => 'required|string', 'content' => 'required|string', ]); $path = $this->sanitizePath($validated['path']); $fullPath = "/home/{$account->username}{$path}"; $tempFile = "/tmp/edit_" . Str::random(16); $content = base64_encode($validated['content']); $result = $this->runtimeFor($account)->executeCommand( $account, "echo '{$content}' | base64 -d > " . escapeshellarg($tempFile) . " && mv " . escapeshellarg($tempFile) . " " . escapeshellarg($fullPath) ); if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to save file'], 500); } return response()->json(['success' => true, 'message' => 'File saved successfully']); } public function createFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'path' => 'required|string', 'name' => 'required|string|max:255', 'type' => 'required|in:file,folder', ]); $path = $this->sanitizePath($validated['path']); $name = basename($validated['name']); $fullPath = "/home/{$account->username}{$path}/{$name}"; if ($validated['type'] === 'folder') { $result = $this->runtimeFor($account)->executeCommand($account, "mkdir -p " . escapeshellarg($fullPath)); } else { $result = $this->runtimeFor($account)->executeCommand($account, "touch " . escapeshellarg($fullPath)); } if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to create ' . $validated['type']], 500); } return response()->json(['success' => true, 'message' => ucfirst($validated['type']) . ' created successfully']); } public function deleteFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'deleteFiles', $account); $account->load(['node']); $validated = $request->validate([ 'path' => 'required|string', ]); $path = $this->sanitizePath($validated['path']); if ($path === '/public_html' || $path === '/' || $path === '') { return response()->json(['success' => false, 'error' => 'Cannot delete this directory'], 400); } $fullPath = "/home/{$account->username}{$path}"; $result = $this->runtimeFor($account)->executeCommand($account, "rm -rf " . escapeshellarg($fullPath)); if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to delete'], 500); } return response()->json(['success' => true, 'message' => 'Deleted successfully']); } public function renameFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); $validated = $request->validate([ 'path' => 'required|string', 'newName' => 'required|string|max:255', ]); $path = $this->sanitizePath($validated['path']); $newName = basename($validated['newName']); $dir = dirname($path); $newPath = "/home/{$account->username}{$dir}/{$newName}"; $oldPath = "/home/{$account->username}{$path}"; $result = $this->runtimeFor($account)->executeCommand($account, "mv " . escapeshellarg($oldPath) . " " . escapeshellarg($newPath)); if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to rename'], 500); } return response()->json(['success' => true, 'message' => 'Renamed successfully']); } public function uploadFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'path' => 'required|string', 'file' => 'required|file|max:51200', // 50MB max ]); $path = $this->sanitizePath($validated['path']); $file = $request->file('file'); $filename = $file->getClientOriginalName(); $fullPath = "/home/{$account->username}{$path}/{$filename}"; // Save the uploaded file to a local temp path first $tmpPath = "/tmp/upload_" . Str::random(16) . "_" . $filename; $file->move('/tmp', basename($tmpPath)); $runtime = $this->runtimeFor($account); // Copy from temp to user directory and set ownership (single command) $result = $runtime->executeCommandAsRoot( $account, "cp " . escapeshellarg($tmpPath) . " " . escapeshellarg($fullPath) . " && chown {$account->username}:{$account->username} " . escapeshellarg($fullPath) . " && chmod 644 " . escapeshellarg($fullPath) . " && rm -f " . escapeshellarg($tmpPath) ); // Clean up temp file if still present @unlink($tmpPath); if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to upload file'], 500); } return response()->json(['success' => true, 'message' => 'File uploaded successfully']); } /** * Initialize a chunked upload session */ public function initChunkedUpload(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'filename' => 'required|string|max:255', 'filesize' => 'required|integer|min:1', 'path' => 'required|string', ]); $uploadId = Str::uuid()->toString(); $tempDir = "/tmp/chunked_uploads/{$uploadId}"; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); } // Store upload metadata file_put_contents("{$tempDir}/metadata.json", json_encode([ 'filename' => $validated['filename'], 'filesize' => $validated['filesize'], 'path' => $this->sanitizePath($validated['path']), 'account_id' => $account->id, 'chunks_received' => [], 'created_at' => now()->toIso8601String(), ])); return response()->json([ 'success' => true, 'upload_id' => $uploadId, ]); } /** * Upload a single chunk */ public function uploadChunk(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'upload_id' => 'required|string|uuid', 'chunk_index' => 'required|integer|min:0', 'chunk' => 'required|file|max:10240', // 10MB per chunk ]); $uploadId = $validated['upload_id']; $chunkIndex = $validated['chunk_index']; $tempDir = "/tmp/chunked_uploads/{$uploadId}"; $metadataPath = "{$tempDir}/metadata.json"; if (!file_exists($metadataPath)) { return response()->json(['success' => false, 'error' => 'Upload session not found or expired'], 404); } $metadata = json_decode(file_get_contents($metadataPath), true); // Verify this upload belongs to this account if ($metadata['account_id'] !== $account->id) { return response()->json(['success' => false, 'error' => 'Unauthorized'], 403); } // Save the chunk and verify size $chunk = $request->file('chunk'); $expectedSize = (int) $request->input('chunk_size', 0); $actualSize = $chunk->getSize(); if ($expectedSize > 0 && $actualSize !== $expectedSize) { return response()->json([ 'success' => false, 'error' => "Chunk size mismatch: expected {$expectedSize}, got {$actualSize}" ], 400); } $chunkPath = "{$tempDir}/chunk_{$chunkIndex}"; $chunk->move($tempDir, "chunk_{$chunkIndex}"); // Verify chunk was saved correctly if (!file_exists($chunkPath) || filesize($chunkPath) !== $actualSize) { return response()->json(['success' => false, 'error' => 'Chunk save failed'], 500); } // Update metadata $metadata['chunks_received'][] = $chunkIndex; $metadata['chunks_received'] = array_unique($metadata['chunks_received']); sort($metadata['chunks_received']); file_put_contents($metadataPath, json_encode($metadata)); return response()->json([ 'success' => true, 'chunk_index' => $chunkIndex, 'chunks_received' => count($metadata['chunks_received']), ]); } /** * Finalize chunked upload - combine chunks and move to destination */ public function finalizeChunkedUpload(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'upload_id' => 'required|string|uuid', 'total_chunks' => 'required|integer|min:1', ]); $uploadId = $validated['upload_id']; $totalChunks = $validated['total_chunks']; $tempDir = "/tmp/chunked_uploads/{$uploadId}"; $metadataPath = "{$tempDir}/metadata.json"; if (!file_exists($metadataPath)) { return response()->json(['success' => false, 'error' => 'Upload session not found or expired'], 404); } $metadata = json_decode(file_get_contents($metadataPath), true); // Verify this upload belongs to this account if ($metadata['account_id'] !== $account->id) { return response()->json(['success' => false, 'error' => 'Unauthorized'], 403); } // Verify all chunks are present if (count($metadata['chunks_received']) !== $totalChunks) { return response()->json([ 'success' => false, 'error' => 'Missing chunks. Expected ' . $totalChunks . ', received ' . count($metadata['chunks_received']), ], 400); } // Combine chunks into final file using streaming to handle large files $finalTempPath = "{$tempDir}/final_" . Str::random(8); $finalFile = fopen($finalTempPath, 'wb'); for ($i = 0; $i < $totalChunks; $i++) { $chunkPath = "{$tempDir}/chunk_{$i}"; if (!file_exists($chunkPath)) { fclose($finalFile); return response()->json(['success' => false, 'error' => "Chunk {$i} is missing"], 400); } // Stream chunk to avoid memory issues with large files $chunkHandle = fopen($chunkPath, 'rb'); while (!feof($chunkHandle)) { fwrite($finalFile, fread($chunkHandle, 8192)); } fclose($chunkHandle); } fclose($finalFile); // Verify combined file size matches expected $combinedSize = filesize($finalTempPath); $expectedSize = (int) ($metadata['filesize'] ?? 0); if ($expectedSize > 0 && abs($combinedSize - $expectedSize) > 1024) { @unlink($finalTempPath); return response()->json([ 'success' => false, 'error' => "File size mismatch. Expected {$expectedSize} bytes, got {$combinedSize} bytes" ], 400); } // Move to user's directory $filename = basename($metadata['filename']); $fullPath = "/home/{$account->username}{$metadata['path']}/{$filename}"; $runtime = $this->runtimeFor($account); $result = $runtime->executeCommandAsRoot( $account, "cp " . escapeshellarg($finalTempPath) . " " . escapeshellarg($fullPath) . " && chown {$account->username}:{$account->username} " . escapeshellarg($fullPath) . " && chmod 644 " . escapeshellarg($fullPath) ); // Clean up temp directory $this->cleanupChunkedUpload($tempDir); if ($result['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Failed to save file to destination'], 500); } return response()->json([ 'success' => true, 'message' => 'File uploaded successfully', 'filename' => $filename, ]); } /** * Clean up a chunked upload directory */ private function cleanupChunkedUpload(string $tempDir): void { if (!is_dir($tempDir)) { return; } $files = glob("{$tempDir}/*"); foreach ($files as $file) { @unlink($file); } @rmdir($tempDir); } public function extractFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'path' => 'required|string', ]); $path = $this->sanitizePath($validated['path']); $fullPath = "/home/{$account->username}{$path}"; $dir = dirname($fullPath); $runtime = $this->runtimeFor($account); // Determine extraction command based on extension $lower = strtolower($fullPath); if (str_ends_with($lower, '.zip')) { // Test zip integrity $test = $runtime->executeCommand( $account, "unzip -tqq " . escapeshellarg($fullPath) . " 2>&1", self::ARCHIVE_COMMAND_TIMEOUT, ); if ($test['exit_code'] !== 0) { return response()->json(['success' => false, 'error' => 'Zip file corrupted or incomplete'], 422); } $cmd = "cd " . escapeshellarg($dir) . " && unzip -oq " . escapeshellarg($fullPath) . " 2>&1"; } elseif (str_ends_with($lower, '.tar.gz') || str_ends_with($lower, '.tgz')) { $cmd = "cd " . escapeshellarg($dir) . " && tar -xzf " . escapeshellarg($fullPath) . " 2>&1"; } elseif (str_ends_with($lower, '.tar.bz2')) { $cmd = "cd " . escapeshellarg($dir) . " && tar -xjf " . escapeshellarg($fullPath) . " 2>&1"; } elseif (str_ends_with($lower, '.tar')) { $cmd = "cd " . escapeshellarg($dir) . " && tar -xf " . escapeshellarg($fullPath) . " 2>&1"; } else { return response()->json(['success' => false, 'error' => 'Unsupported archive format. Supported: .zip, .tar.gz, .tgz, .tar.bz2, .tar'], 422); } // Get file count before extraction $totalFiles = 0; if (str_ends_with($lower, '.zip')) { $countResult = $runtime->executeCommand( $account, "unzip -Z1 " . escapeshellarg($fullPath) . " 2>/dev/null | wc -l", self::ARCHIVE_COMMAND_TIMEOUT, ); $totalFiles = max(0, (int) trim($countResult['output'] ?? '0')); } else { $listFlag = str_ends_with($lower, '.tar.gz') || str_ends_with($lower, '.tgz') ? '-tzf' : (str_ends_with($lower, '.tar.bz2') ? '-tjf' : '-tf'); $countResult = $runtime->executeCommand( $account, "tar {$listFlag} " . escapeshellarg($fullPath) . " 2>/dev/null | wc -l", self::ARCHIVE_COMMAND_TIMEOUT, ); $totalFiles = max(0, (int) trim($countResult['output'] ?? '0')); } $result = $runtime->executeCommand($account, $cmd, self::ARCHIVE_COMMAND_TIMEOUT); if ($result['exit_code'] !== 0) { $error = trim($result['output'] ?? ''); return response()->json(['success' => false, 'error' => 'Extraction failed' . ($error ? ': ' . Str::limit($error, 200) : '')], 500); } $extractedFiles = $totalFiles; // Fix permissions $runtime->executeCommand( $account, "find " . escapeshellarg($dir) . " -type f -exec chmod 644 {} + 2>/dev/null; find " . escapeshellarg($dir) . " -type d -exec chmod 755 {} + 2>/dev/null", self::ARCHIVE_COMMAND_TIMEOUT, ); return response()->json([ 'success' => true, 'message' => 'Archive extracted successfully', 'total_files' => $totalFiles, 'extracted_files' => $extractedFiles, ]); } public function bulkDelete(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'deleteFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'paths' => 'required|array|min:1', 'paths.*' => 'required|string', ]); $runtime = $this->runtimeFor($account); $errors = []; foreach ($validated['paths'] as $path) { $sanitized = $this->sanitizePath($path); $fullPath = "/home/{$account->username}{$sanitized}"; $result = $runtime->executeCommand($account, "rm -rf " . escapeshellarg($fullPath)); if ($result['exit_code'] !== 0) { $errors[] = basename($sanitized); } } if ($errors) { return response()->json(['success' => false, 'error' => 'Failed to delete: ' . implode(', ', $errors)], 500); } return response()->json(['success' => true, 'message' => count($validated['paths']) . ' item(s) deleted']); } public function bulkMove(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'paths' => 'required|array|min:1', 'paths.*' => 'required|string', 'destination' => 'required|string', ]); $runtime = $this->runtimeFor($account); $dest = $this->sanitizePath($validated['destination']); $destFull = "/home/{$account->username}{$dest}"; $errors = []; foreach ($validated['paths'] as $path) { $sanitized = $this->sanitizePath($path); $fullPath = "/home/{$account->username}{$sanitized}"; $result = $runtime->executeCommand($account, "mv " . escapeshellarg($fullPath) . " " . escapeshellarg($destFull . "/" . basename($sanitized))); if ($result['exit_code'] !== 0) { $errors[] = basename($sanitized); } } if ($errors) { return response()->json(['success' => false, 'error' => 'Failed to move: ' . implode(', ', $errors)], 500); } return response()->json(['success' => true, 'message' => count($validated['paths']) . ' item(s) moved']); } public function bulkCopy(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'paths' => 'required|array|min:1', 'paths.*' => 'required|string', 'destination' => 'required|string', ]); $runtime = $this->runtimeFor($account); $dest = $this->sanitizePath($validated['destination']); $destFull = "/home/{$account->username}{$dest}"; $errors = []; foreach ($validated['paths'] as $path) { $sanitized = $this->sanitizePath($path); $fullPath = "/home/{$account->username}{$sanitized}"; $result = $runtime->executeCommand($account, "cp -r " . escapeshellarg($fullPath) . " " . escapeshellarg($destFull . "/" . basename($sanitized))); if ($result['exit_code'] !== 0) { $errors[] = basename($sanitized); } } if ($errors) { return response()->json(['success' => false, 'error' => 'Failed to copy: ' . implode(', ', $errors)], 500); } return response()->json(['success' => true, 'message' => count($validated['paths']) . ' item(s) copied']); } public function bulkCompress(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'paths' => 'required|array|min:1', 'paths.*' => 'required|string', 'archive_name' => 'required|string|max:255', 'base_path' => 'required|string', ]); $runtime = $this->runtimeFor($account); $basePath = $this->sanitizePath($validated['base_path']); $baseFullPath = "/home/{$account->username}{$basePath}"; $archiveName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $validated['archive_name']); if (!str_ends_with(strtolower($archiveName), '.zip')) { $archiveName .= '.zip'; } $archivePath = $baseFullPath . '/' . $archiveName; $items = []; foreach ($validated['paths'] as $path) { $sanitized = $this->sanitizePath($path); $items[] = escapeshellarg(basename($sanitized)); } $cmd = "cd " . escapeshellarg($baseFullPath) . " && zip -r " . escapeshellarg($archiveName) . " " . implode(' ', $items) . " 2>&1"; $result = $runtime->executeCommand($account, $cmd); if ($result['exit_code'] !== 0) { $error = trim($result['output'] ?? ''); return response()->json(['success' => false, 'error' => 'Compression failed' . ($error ? ': ' . Str::limit($error, 200) : '')], 500); } return response()->json(['success' => true, 'message' => 'Archive created: ' . $archiveName]); } public function chmodFile(Request $request, HostingAccount $account): JsonResponse { $this->authorizeForRequestUser($request, 'manageFiles', $account); $account->load(['node']); if ($response = $this->guardWritableAccount($account)) { return $response; } $validated = $request->validate([ 'paths' => 'required|array|min:1', 'paths.*' => 'required|string', 'permissions' => ['required', 'string', 'regex:/^[0-7]{3,4}$/'], ]); $runtime = $this->runtimeFor($account); $errors = []; foreach ($validated['paths'] as $path) { $sanitized = $this->sanitizePath($path); $fullPath = "/home/{$account->username}{$sanitized}"; $result = $runtime->executeCommand($account, "chmod " . escapeshellarg($validated['permissions']) . " " . escapeshellarg($fullPath)); if ($result['exit_code'] !== 0) { $errors[] = basename($sanitized); } } if ($errors) { return response()->json(['success' => false, 'error' => 'Failed to change permissions: ' . implode(', ', $errors)], 500); } return response()->json(['success' => true, 'message' => 'Permissions updated']); } // ==================== DATABASE MANAGER ==================== public function databases(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'manageDatabases', $account); $account->load(['node', 'databases']); return view('hosting.panel.databases', [ 'account' => $account, 'phpMyAdminUrl' => $this->phpMyAdminUrlForAccount($account), ]); } public function createDatabase(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'manageDatabases', $account); $account->load(['node', 'product', 'databases']); $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; if ($account->databases->count() >= $maxDatabases) { return back()->with('error', "You have reached the maximum number of databases ({$maxDatabases})."); } $validated = $request->validate([ 'name' => 'required|string|max:32|regex:/^[a-zA-Z0-9_]+$/', ]); $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); $dbName = $prefix . '_' . $validated['name']; $dbUser = $dbName; $dbPassword = Str::password(16, true, true, false, false); $database = HostedDatabase::create([ 'hosting_account_id' => $account->id, 'name' => $dbName, 'username' => $dbUser, 'password_encrypted' => encrypt($dbPassword), 'host' => 'localhost', ]); try { $this->runtimeFor($account)->createDatabase($database, $dbPassword); return back()->with('success', "Database created successfully. Username: {$dbUser}, Password: {$dbPassword}"); } catch (\Exception $e) { $database->delete(); Log::error("Failed to create database: " . $e->getMessage()); return back()->with('error', 'Failed to create database. Please try again.'); } } public function deleteDatabase(Request $request, HostingAccount $account, HostedDatabase $database): RedirectResponse { $this->authorizeForRequestUser($request, 'manageDatabases', $account); $this->ensureDatabaseBelongsToAccount($database, $account); $account->load(['node']); try { $this->runtimeFor($account)->deleteDatabase($database); $database->delete(); return back()->with('success', 'Database deleted successfully.'); } catch (\Exception $e) { Log::error("Failed to delete database: " . $e->getMessage()); return back()->with('error', 'Failed to delete database. Please try again.'); } } public function resetDatabasePassword(Request $request, HostingAccount $account, HostedDatabase $database): JsonResponse { $this->authorizeForRequestUser($request, 'manageDatabases', $account); $this->ensureDatabaseBelongsToAccount($database, $account); $account->load(['node']); $newPassword = Str::password(16, true, true, false, false); try { $this->runtimeFor($account)->changeDatabasePassword($database, $newPassword); // Store the new encrypted password for SSO $database->update(['password_encrypted' => encrypt($newPassword)]); return response()->json([ 'success' => true, 'password' => $newPassword, 'message' => 'Password reset successfully.', ]); } catch (\Exception $e) { Log::error("Failed to reset database password: " . $e->getMessage()); return response()->json(['success' => false, 'error' => 'Failed to reset password.'], 500); } } // ==================== DOMAIN MANAGER ==================== public function domains(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'viewDomains', $account); $account->load(['node', 'product', 'sites']); $ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account); return view('hosting.panel.domains', [ 'account' => $account, 'ownedDomains' => $ownedDomains, ]); } public function addDomain( Request $request, HostingAccount $account, DomainDnsBlueprintService $blueprints, DomainVerificationService $verification ): RedirectResponse|\Illuminate\Http\JsonResponse { $this->authorizeForRequestUser($request, 'manageDomains', $account); $account->load(['node', 'product', 'sites']); $maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1; if ($account->sites->count() >= $maxDomains) { if ($request->wantsJson()) { return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422); } return back()->with('error', "You have reached the maximum number of domains ({$maxDomains})."); } $isOwnedDomain = $request->boolean('is_owned_domain'); $rules = [ 'domain' => 'required|string|max:255|regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/', 'document_root' => 'nullable|string|max:255', ]; if (! $isOwnedDomain) { $rules['onboarding_mode'] = 'nullable|string|in:ns_auto,manual_dns'; } $validated = $request->validate($rules); $domainHost = strtolower($validated['domain']); $onboardingMode = $isOwnedDomain ? Domain::MODE_NS_AUTO : ($validated['onboarding_mode'] ?? Domain::MODE_NS_AUTO); $serverIp = $account->node?->ip_address ?? '161.97.138.149'; $docRoot = ! empty($validated['document_root']) ? "/home/{$account->username}/" . ltrim($validated['document_root'], '/') : "/home/{$account->username}/public_html/{$domainHost}"; // Check if domain is already linked (include soft-deleted to handle unique constraint) $existingSite = HostedSite::withTrashed()->where('domain', $domainHost)->first(); if ($existingSite) { if ($existingSite->trashed()) { if ($existingSite->hosting_account_id !== $account->id) { if ($request->wantsJson()) { return response()->json(['message' => 'This domain is already in use on another hosting account.'], 422); } return back()->with('error', 'This domain is already in use on another hosting account.'); } // Restore the soft-deleted site and recreate nginx config $existingSite->restore(); $existingSite->fill([ 'document_root' => $docRoot, 'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2', 'status' => 'active', 'type' => $existingSite->type ?: 'addon', ])->save(); try { $existingSite->loadMissing(['account.node']); $this->runtimeFor($account)->addSite($existingSite); // Queue SSL provisioning for restored domain ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2)); if ($request->wantsJson()) { return response()->json(['success' => true, 'message' => "Domain {$domainHost} has been restored. SSL will be provisioned automatically."]); } return back()->with('success', "Domain {$domainHost} has been restored. SSL will be provisioned automatically."); } catch (\Exception $e) { $existingSite->delete(); Log::error("Failed to restore domain nginx config: " . $e->getMessage()); if ($request->wantsJson()) { return response()->json(['message' => 'Failed to restore domain. Please try again.'], 500); } return back()->with('error', 'Failed to restore domain. Please try again.'); } } if ($existingSite->hosting_account_id !== $account->id) { if ($request->wantsJson()) { return response()->json(['message' => 'This domain is already in use on another hosting account.'], 422); } return back()->with('error', 'This domain is already in use on another hosting account.'); } // Site already exists on this account if ($request->wantsJson()) { return response()->json(['success' => true, 'message' => "Domain {$domainHost} is already linked to this hosting account."]); } return back()->with('success', "Domain {$domainHost} is already linked to this hosting account."); } // Check if domain already exists in the system, reuse or create new $domain = Domain::where('host', $domainHost)->first(); if (!$domain) { $domain = Domain::create([ 'user_id' => $account->user_id, 'hosting_account_id' => $account->id, 'host' => $domainHost, 'type' => 'custom', 'source' => 'hosting', 'onboarding_mode' => $onboardingMode, 'verification_token' => bin2hex(random_bytes(16)), 'status' => 'verified', 'onboarding_state' => Domain::STATE_ACTIVE, 'dns_mode' => $onboardingMode === Domain::MODE_MANUAL_DNS ? 'manual' : 'managed', 'ns_expected' => $blueprints->expectedNameservers(), 'verified_at' => now(), 'connected_at' => now(), 'mail_ready_at' => now(), 'active_at' => now(), 'verification_meta' => [ 'created_via' => $onboardingMode, 'mail_setup' => $blueprints->mailSetupInstructions(), 'hosting_account_id' => $account->id, 'server_ip' => $serverIp, ], ]); } elseif ($domain->user_id !== $account->user_id) { if ($request->wantsJson()) { return response()->json(['message' => 'This domain is already registered to another user.'], 422); } return back()->with('error', 'This domain is already registered to another user.'); } else { $verificationMeta = (array) $domain->verification_meta; $domain->update([ 'hosting_account_id' => $account->id, 'onboarding_mode' => $domain->isActiveForMail() ? $domain->onboarding_mode : $onboardingMode, 'dns_mode' => $domain->isActiveForMail() ? $domain->dns_mode : ($onboardingMode === Domain::MODE_MANUAL_DNS ? 'manual' : 'managed'), 'ns_expected' => $blueprints->expectedNameservers(), 'verification_meta' => array_merge($verificationMeta, [ 'created_via' => $domain->isActiveForMail() ? ($verificationMeta['created_via'] ?? $domain->onboarding_mode) : $onboardingMode, 'mail_setup' => $blueprints->mailSetupInstructions(), 'hosting_account_id' => $account->id, 'server_ip' => $serverIp, ]), ]); } $site = HostedSite::create([ 'hosting_account_id' => $account->id, 'domain_id' => $domain->id, 'domain' => $domainHost, 'document_root' => $docRoot, 'type' => 'addon', 'php_version' => $account->php_version ?? '8.2', 'ssl_enabled' => false, 'status' => 'active', ]); try { $this->runtimeFor($account)->addSite($site); // Queue DNS zone provisioning (manual pack is local-only). if ($onboardingMode === Domain::MODE_MANUAL_DNS) { $verification->prepareManualDnsPack($domain); } else { $verification->reprovision($domain); } // Queue SSL provisioning (delayed to allow DNS propagation) ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2)); $message = $onboardingMode === Domain::MODE_MANUAL_DNS ? "Domain {$domainHost} linked successfully. SSL will be provisioned automatically once DNS is configured." : "Domain {$domainHost} linked successfully. SSL will be provisioned automatically once nameservers are updated."; if ($request->wantsJson()) { return response()->json(['success' => true, 'message' => $message, 'domain' => $domain->only('id', 'host', 'status')]); } return back()->with('success', $message); } catch (\Exception $e) { $site->delete(); // Don't delete the domain record as it may be used elsewhere Log::error("Failed to add domain: " . $e->getMessage()); if ($request->wantsJson()) { return response()->json(['message' => 'Failed to add domain. Please try again.'], 500); } return back()->with('error', 'Failed to add domain. Please try again.'); } } public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse { abort_unless((int) $account->user_id === (int) $request->user()->id, 403); $this->ensureSiteBelongsToAccount($site, $account); $account->load(['node']); try { $this->runtimeFor($account)->removeSite($site); $site->delete(); return back()->with('success', 'Domain removed successfully.'); } catch (\Exception $e) { Log::error("Failed to remove domain: " . $e->getMessage()); return back()->with('error', 'Failed to remove domain. Please try again.'); } } private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection { $linkedDomains = HostedSite::whereNotNull('domain') ->pluck('domain') ->map(fn ($d) => strtolower(trim((string) $d))) ->filter() ->all(); $domainRegistryHosts = Domain::where('user_id', $userId)->pluck('host'); $registeredDomains = RcServiceOrder::where('user_id', $userId) ->whereIn('order_type', [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER]) ->where('status', RcServiceOrder::STATUS_ACTIVE) ->whereNotNull('domain_name') ->pluck('domain_name'); $customerHostingDomains = CustomerHostingOrder::where('user_id', $userId) ->whereNotIn('status', [ CustomerHostingOrder::STATUS_PENDING_PAYMENT, CustomerHostingOrder::STATUS_CANCELLED, CustomerHostingOrder::STATUS_FAILED, ]) ->whereNotNull('domain_name') ->pluck('domain_name'); $hostingAccountDomains = HostingAccount::where('user_id', $userId) ->whereNotNull('primary_domain') ->pluck('primary_domain'); return $domainRegistryHosts ->merge($registeredDomains) ->merge($customerHostingDomains) ->merge($hostingAccountDomains) ->map(fn ($d) => strtolower(trim((string) $d))) ->filter(fn ($d) => (bool) preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $d)) ->reject(fn ($d) => in_array($d, $linkedDomains, true)) ->unique() ->sort() ->values(); } // ==================== PHP CONFIGURATION ==================== public function phpConfig(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'managePhp', $account); $account->load(['node', 'product']); $availableVersions = ['8.0', '8.1', '8.2', '8.3', '8.4']; $currentVersion = $account->php_version ?? '8.4'; // Read current php.ini settings $phpSettings = $this->getPhpSettings($account); return view('hosting.panel.php', [ 'account' => $account, 'availableVersions' => $availableVersions, 'currentVersion' => $currentVersion, 'phpSettings' => $phpSettings, ]); } public function changePhpVersion(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'managePhp', $account); $account->load(['node', 'sites']); $validated = $request->validate([ 'php_version' => 'required|string|in:8.0,8.1,8.2,8.3,8.4', ]); try { $newVersion = $validated['php_version']; $currentVersion = $account->php_version ?? '8.4'; if ($currentVersion === $newVersion) { return back()->with('success', "PHP version is already {$newVersion}."); } if (! $this->runtimeFor($account)->changeAccountPhpVersion($account, $newVersion)) { return back()->with('error', 'Failed to change PHP version. Please try again.'); } $account->update(['php_version' => $newVersion]); foreach ($account->sites as $site) { $site->update(['php_version' => $newVersion]); } return back()->with('success', "PHP version changed to {$newVersion} successfully."); } catch (\Exception $e) { Log::error("Failed to change PHP version: " . $e->getMessage()); return back()->with('error', 'Failed to change PHP version. Please try again.'); } } public function savePhpSettings(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'managePhp', $account); $account->load(['node']); $validated = $request->validate([ 'upload_max_filesize' => 'required|integer|min:1|max:512', 'post_max_size' => 'required|integer|min:1|max:512', 'memory_limit' => 'required|integer|min:32|max:1024', 'max_execution_time' => 'required|integer|min:30|max:600', 'max_input_vars' => 'required|integer|min:1000|max:10000', ]); try { $this->saveUserPhpIni($account, $validated); return back()->with('success', 'PHP settings saved successfully.'); } catch (\Exception $e) { Log::error("Failed to save PHP settings: " . $e->getMessage()); return back()->with('error', 'Failed to save PHP settings. Please try again.'); } } private function getPhpSettings(HostingAccount $account): array { $defaults = [ 'upload_max_filesize' => 64, 'post_max_size' => 64, 'memory_limit' => 256, 'max_execution_time' => 300, 'max_input_vars' => 3000, ]; $iniPath = "/home/{$account->username}/.user.ini"; $result = $this->runtimeFor($account)->executeCommand($account, "cat " . escapeshellarg($iniPath) . " 2>/dev/null"); if ($result['exit_code'] !== 0 || empty(trim($result['output']))) { return $defaults; } $settings = $defaults; foreach (explode("\n", $result['output']) as $line) { if (preg_match('/^(\w+)\s*=\s*(\d+)/', $line, $matches)) { $key = $matches[1]; $value = (int) $matches[2]; if (isset($settings[$key])) { $settings[$key] = $value; } } } return $settings; } private function saveUserPhpIni(HostingAccount $account, array $settings): void { $content = "; Custom PHP settings for {$account->username}\n"; $content .= "; Generated by Ladill Hosting Panel\n\n"; $content .= "upload_max_filesize = {$settings['upload_max_filesize']}M\n"; $content .= "post_max_size = {$settings['post_max_size']}M\n"; $content .= "memory_limit = {$settings['memory_limit']}M\n"; $content .= "max_execution_time = {$settings['max_execution_time']}\n"; $content .= "max_input_vars = {$settings['max_input_vars']}\n"; $iniPath = "/home/{$account->username}/.user.ini"; $encoded = base64_encode($content); $this->runtimeFor($account)->executeCommand( $account, "echo '{$encoded}' | base64 -d > " . escapeshellarg($iniPath) ); // Also copy to public_html for PHP-FPM to pick up $publicIniPath = "/home/{$account->username}/public_html/.user.ini"; $this->runtimeFor($account)->executeCommand( $account, "cp " . escapeshellarg($iniPath) . " " . escapeshellarg($publicIniPath) ); } // ==================== SSL MANAGEMENT ==================== public function ssl(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'manageSsl', $account); $account->load(['node', 'sites']); return view('hosting.panel.ssl', [ 'account' => $account, ]); } public function requestSsl(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse { $this->authorizeForRequestUser($request, 'manageSsl', $account); $this->ensureSiteBelongsToAccount($site, $account); $account->load(['node']); try { $this->runtimeFor($account)->requestLetsEncryptCertificate($site); $site->update([ 'ssl_enabled' => true, 'ssl_status' => 'issued', 'ssl_provisioned_at' => now(), 'ssl_error' => null, ]); return back()->with('success', "SSL certificate issued for {$site->domain} successfully."); } catch (\Exception $e) { Log::error("Failed to request SSL: " . $e->getMessage()); $message = trim($e->getMessage()); return back()->with( 'error', $message !== '' ? 'Failed to issue SSL certificate: ' . $message : 'Failed to issue SSL certificate. Make sure your domain DNS is pointing to this server.' ); } } // ==================== CRON JOBS ==================== public function cronJobs(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'manageCron', $account); $account->load(['node']); $cronJobs = $this->getCronJobs($account); return view('hosting.panel.cron', [ 'account' => $account, 'cronJobs' => $cronJobs, ]); } public function addCronJob(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'manageCron', $account); $account->load(['node']); $validated = $request->validate([ 'minute' => 'required|string|max:10', 'hour' => 'required|string|max:10', 'day' => 'required|string|max:10', 'month' => 'required|string|max:10', 'weekday' => 'required|string|max:10', 'command' => 'required|string|max:500', ]); try { $cronLine = "{$validated['minute']} {$validated['hour']} {$validated['day']} {$validated['month']} {$validated['weekday']} {$validated['command']}"; $this->addCronEntry($account, $cronLine); return back()->with('success', 'Cron job added successfully.'); } catch (\Exception $e) { Log::error("Failed to add cron job: " . $e->getMessage()); return back()->with('error', 'Failed to add cron job. Please try again.'); } } public function deleteCronJob(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'manageCron', $account); $account->load(['node']); $validated = $request->validate([ 'line_number' => 'required|integer|min:1', ]); try { $this->deleteCronEntry($account, $validated['line_number']); return back()->with('success', 'Cron job deleted successfully.'); } catch (\Exception $e) { Log::error("Failed to delete cron job: " . $e->getMessage()); return back()->with('error', 'Failed to delete cron job. Please try again.'); } } private function getCronJobs(HostingAccount $account): array { $result = $this->runtimeFor($account)->executeCommand($account, "crontab -l 2>/dev/null"); if ($result['exit_code'] !== 0 || empty(trim($result['output']))) { return []; } $jobs = []; $lineNumber = 0; foreach (explode("\n", $result['output']) as $line) { $lineNumber++; $line = trim($line); if (empty($line) || str_starts_with($line, '#')) continue; if (preg_match('/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $matches)) { $jobs[] = [ 'line_number' => $lineNumber, 'minute' => $matches[1], 'hour' => $matches[2], 'day' => $matches[3], 'month' => $matches[4], 'weekday' => $matches[5], 'command' => $matches[6], 'schedule' => "{$matches[1]} {$matches[2]} {$matches[3]} {$matches[4]} {$matches[5]}", ]; } } return $jobs; } private function addCronEntry(HostingAccount $account, string $cronLine): void { $this->runtimeFor($account)->executeCommand( $account, "(crontab -l 2>/dev/null; echo " . escapeshellarg($cronLine) . ") | crontab -" ); } private function deleteCronEntry(HostingAccount $account, int $lineNumber): void { $this->runtimeFor($account)->executeCommand( $account, "crontab -l 2>/dev/null | sed '{$lineNumber}d' | crontab -" ); } // ==================== ERROR LOGS ==================== public function logs(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'viewLogs', $account); $account->load(['node', 'sites']); $logType = $request->get('type', 'error'); $lines = (int) $request->get('lines', 100); $lines = min(max($lines, 50), 500); $logContent = $this->getLogContent($account, $logType, $lines); $logFilePath = $this->getLogFilePath($account, $logType); $errorLogPath = $this->getLogFilePath($account, 'error'); $accessLogPath = $this->getLogFilePath($account, 'access'); return view('hosting.panel.logs', [ 'account' => $account, 'logType' => $logType, 'lines' => $lines, 'logContent' => $logContent, 'logFilePath' => $logFilePath, 'errorLogPath' => $errorLogPath, 'accessLogPath' => $accessLogPath, ]); } public function clearLogs(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'clearLogs', $account); $account->load(['node']); $logType = $request->get('type', 'error'); try { $logFile = $this->getLogFilePath($account, $logType); $this->runtimeFor($account)->executeCommand($account, "truncate -s 0 " . escapeshellarg($logFile) . " 2>/dev/null"); return back()->with('success', 'Log file cleared successfully.'); } catch (\Exception $e) { return back()->with('error', 'Failed to clear log file: ' . $e->getMessage()); } } private function getLogFilePath(HostingAccount $account, string $type): string { return $this->getLogFileCandidates($account, $type)[0]; } private function getLogContent(HostingAccount $account, string $type, int $lines = 100): string { $candidates = $this->getLogFileCandidates($account, $type); foreach ($candidates as $logFile) { $result = $this->runtimeFor($account)->executeCommand( $account, "test -f " . escapeshellarg($logFile) . " && tail -n {$lines} " . escapeshellarg($logFile) . " 2>/dev/null" ); if (($result['exit_code'] ?? 1) === 0 && trim((string) ($result['output'] ?? '')) !== '') { return (string) $result['output']; } } return 'Log file not found or is empty.'; } private function getLogFileCandidates(HostingAccount $account, string $type): array { $logSuffix = $type === 'access' ? 'access' : 'error'; $basePath = "/home/{$account->username}/logs"; $candidates = []; $primaryDomain = $this->getPrimaryLogDomain($account); if ($primaryDomain) { $candidates[] = "{$basePath}/{$primaryDomain}-{$logSuffix}.log"; } $candidates[] = "{$basePath}/{$logSuffix}.log"; return array_values(array_unique($candidates)); } private function getPrimaryLogDomain(HostingAccount $account): ?string { return $account->primary_domain ?: $account->sites->first()?->domain; } // ==================== APP INSTALLER ==================== public function apps(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'manageApps', $account); $account->load(['node', 'product', 'sites.domain']); $isWordPressPlan = $account->product?->type === \App\Models\HostingProduct::TYPE_WORDPRESS; $allApps = [ [ 'id' => 'wordpress', 'name' => 'WordPress', 'description' => 'Popular CMS and blogging platform', 'icon' => 'wordpress', 'category' => 'cms', ], [ 'id' => 'joomla', 'name' => 'Joomla', 'description' => 'Flexible CMS for websites and apps', 'icon' => 'joomla', 'category' => 'cms', ], [ 'id' => 'drupal', 'name' => 'Drupal', 'description' => 'Enterprise-grade CMS platform', 'icon' => 'drupal', 'category' => 'cms', ], [ 'id' => 'opencart', 'name' => 'OpenCart', 'description' => 'Free e-commerce platform', 'icon' => 'opencart', 'category' => 'ecommerce', ], [ 'id' => 'magento', 'name' => 'Magento', 'description' => 'Powerful e-commerce solution', 'icon' => 'magento', 'category' => 'ecommerce', ], [ 'id' => 'laravel', 'name' => 'Laravel', 'description' => 'PHP web application framework', 'icon' => 'laravel', 'category' => 'framework', ], [ 'id' => 'nodejs', 'name' => 'Node.js App', 'description' => 'JavaScript runtime application', 'icon' => 'nodejs', 'category' => 'runtime', ], [ 'id' => 'python', 'name' => 'Python App', 'description' => 'Python WSGI application', 'icon' => 'python', 'category' => 'runtime', ], ]; $availableApps = $isWordPressPlan ? array_values(array_filter($allApps, fn ($app) => $app['id'] === 'wordpress')) : $allApps; return view('hosting.panel.apps', [ 'account' => $account, 'availableApps' => $availableApps, 'isWordPressPlan' => $isWordPressPlan, ]); } public function installApp(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'manageApps', $account); $account->load(['node', 'product', 'databases', 'user']); $validated = $request->validate([ 'app' => 'required|string|in:wordpress,joomla,drupal,opencart,magento,laravel,nodejs,python', 'site_id' => 'required|exists:hosted_sites,id', 'directory' => 'nullable|string|max:100|regex:/^[a-zA-Z0-9_-]*$/', 'php_version' => 'nullable|string|in:8.1,8.2,8.3,8.4', ]); $site = HostedSite::findOrFail($validated['site_id']); $this->ensureSiteBelongsToAccount($site, $account); // Check if site already has an app installed if ($site->installed_app) { return back()->with('error', 'This site already has an application installed. Please delete it first.'); } // Check if there's already an installation in progress $existingInstallation = AppInstallation::where('hosted_site_id', $site->id) ->where('status', 'installing') ->first(); if ($existingInstallation) { return back()->with('error', 'An installation is already in progress for this site.'); } // Delete any failed installations for this site (user is retrying) AppInstallation::where('hosted_site_id', $site->id) ->where('status', 'failed') ->delete(); $defaultDocRoot = $this->defaultSiteDocumentRoot($account, $site); if ($this->hasAppSpecificDocumentRoot($site->document_root, $defaultDocRoot)) { $this->runtimeFor($account)->restoreDefaultSiteConfig($account, $site, $defaultDocRoot); $site->update(['document_root' => $defaultDocRoot]); $site->refresh(); } $directory = $validated['directory'] ?? ''; $docRoot = $site->document_root; $installPath = $directory ? "{$docRoot}/{$directory}" : $docRoot; $displayPath = $directory ? "{$site->domain}/{$directory}" : $site->domain; $installUrl = ($site->ssl_enabled ? 'https://' : 'http://') . $displayPath; // Determine PHP version (default to 8.4 for PHP apps, null for non-PHP apps) $phpApps = ['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel']; $phpVersion = in_array($validated['app'], $phpApps) ? ($validated['php_version'] ?? '8.4') : null; // Create AppInstallation record with installing status $installation = AppInstallation::create([ 'hosted_site_id' => $site->id, 'app_type' => $validated['app'], 'app_version' => 'latest', 'install_url' => $installUrl, 'status' => 'installing', 'progress' => 0, 'progress_message' => 'Queued for installation...', 'total_steps' => $this->getInstallationSteps($validated['app']), 'config' => $phpVersion ? ['php_version' => $phpVersion] : null, ]); // Dispatch the installation job to Redis queue \App\Jobs\AppInstallationJob::dispatch( $installation, $account, $site, $installPath, $installUrl ); return back()->with('success', ucfirst($validated['app']) . " installation started for {$displayPath}. You can track the progress below."); } public function installationProgress(Request $request, HostingAccount $account, AppInstallation $installation): JsonResponse { $this->authorizeForRequestUser($request, 'manageApps', $account); abort_if((int) $installation->site->hosting_account_id !== (int) $account->id, 403); return response()->json([ 'id' => $installation->id, 'status' => $installation->status, 'progress' => $installation->progress, 'progress_message' => $installation->progress_message, 'current_step' => $installation->current_step, 'total_steps' => $installation->total_steps, 'error_message' => $installation->error_message, 'app_type' => $installation->app_type, 'installed_at' => $installation->installed_at?->toIso8601String(), ]); } private function getInstallationSteps(string $appType): int { return match ($appType) { 'wordpress' => 6, 'joomla' => 5, 'drupal' => 5, 'opencart' => 5, 'magento' => 6, 'laravel' => 4, 'nodejs' => 3, 'python' => 3, default => 5, }; } public function deleteApp(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse { $this->authorizeForRequestUser($request, 'manageApps', $account); $this->ensureSiteBelongsToAccount($site, $account); if (!$site->installed_app) { return back()->with('error', 'No application installed on this site.'); } $appName = ucfirst($site->installed_app); $appType = $site->installed_app; $account->load('node'); try { // For Python/Node.js apps, stop the service and restore nginx to PHP-FPM if (in_array($appType, ['python', 'nodejs'], true)) { $this->runtimeFor($account)->removeAppService($site); // Clean up Node.js specific files if ($appType === 'nodejs') { $docRoot = $site->document_root; // Remove node_modules and package files $this->runtimeFor($account)->executeCommand( $account, "rm -rf " . escapeshellarg($docRoot) . "/node_modules " . escapeshellarg($docRoot) . "/package.json " . escapeshellarg($docRoot) . "/package-lock.json " . escapeshellarg($docRoot) . "/npm-debug.log 2>&1 || true" ); } // Clean up Python specific files if ($appType === 'python') { $docRoot = $site->document_root; // Remove virtual environment and Python files $this->runtimeFor($account)->executeCommand( $account, "rm -rf " . escapeshellarg($docRoot) . "/venv " . escapeshellarg($docRoot) . "/__pycache__ " . escapeshellarg($docRoot) . "/.venv " . escapeshellarg($docRoot) . "/requirements.txt 2>&1 || true" ); // Remove all .pyc files $this->runtimeFor($account)->executeCommand( $account, "find " . escapeshellarg($docRoot) . " -name '*.pyc' -delete 2>&1 || true" ); } } // For PHP apps that may have changed document root, restore nginx config first if (in_array($appType, ['laravel', 'magento'], true)) { // Restore nginx to default PHP-FPM config (before deleting files) $defaultDocRoot = $this->defaultSiteDocumentRoot($account, $site); $this->runtimeFor($account)->restoreDefaultSiteConfig($account, $site, $defaultDocRoot); $site->update(['document_root' => $defaultDocRoot]); $site->refresh(); } $docRoot = $site->document_root; // Use root to forcefully remove all files (handles permission issues) // First try as user, then as root if needed $result = $this->runtimeFor($account)->executeCommand( $account, "rm -rf " . escapeshellarg($docRoot) . "/* " . escapeshellarg($docRoot) . "/.[!.]* 2>&1" ); // If user-level deletion had issues, force cleanup as root if ($result['exit_code'] !== 0 || $this->hasFilesRemaining($account, $docRoot)) { $this->runtimeFor($account)->executeCommandAsRoot( $account, "rm -rf " . escapeshellarg($docRoot) . "/* " . escapeshellarg($docRoot) . "/.[!.]* " . escapeshellarg($docRoot) . "/..?* 2>&1 || true" ); } // For apps with subdirectories (Laravel/Magento), clean parent directory too if (in_array($appType, ['laravel', 'magento'], true)) { $parentDir = dirname($docRoot); // Check if parent is not just the account's home root if ($parentDir !== "/home/{$account->username}" && $parentDir !== "/home/{$account->username}/public_html") { $this->runtimeFor($account)->executeCommandAsRoot( $account, "rm -rf " . escapeshellarg($parentDir) . " 2>&1 || true" ); } } // Clean up database and database user properly if ($site->appInstallation && $site->appInstallation->database_name) { $dbName = $site->appInstallation->database_name; $database = $account->databases()->where('name', $dbName)->first(); if ($database) { $dbUser = $database->username; // Use actual username from database record // Drop database and user separately for clarity $this->runtimeFor($account)->executeCommandAsRoot( $account, "mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbUser}'@'localhost';\" 2>&1" ); $database->delete(); } else { // Database record not found, try to clean up anyway with the stored name $this->runtimeFor($account)->executeCommandAsRoot( $account, "mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbName}'@'localhost';\" 2>&1 || true" ); } } // Also call AppInstaller.uninstall for additional cleanup if available try { $appInstaller = app(\App\Services\Hosting\AppInstaller::class); if ($site->appInstallation) { $appInstaller->uninstall($site->appInstallation); } } catch (\Exception $e) { Log::warning("AppInstaller uninstall failed (non-critical): " . $e->getMessage()); } if ($site->appInstallation) { $site->appInstallation->delete(); } $site->update([ 'installed_app' => null, 'installed_app_version' => null, 'app_config' => null, ]); if ($appType === 'magento') { $opensearchCheck = $this->runtimeFor($account)->executeCommandAsRoot( $account, '/usr/local/bin/check-opensearch-for-magento.sh 2>&1' ); if (($opensearchCheck['exit_code'] ?? 0) !== 0) { Log::warning('Failed to refresh Magento OpenSearch state after Magento deletion.', [ 'account_id' => $account->id, 'site_id' => $site->id, 'output' => trim((string) ($opensearchCheck['output'] ?? '')), ]); } } return back()->with('success', "{$appName} has been uninstalled successfully."); } catch (\Exception $e) { Log::error("Failed to delete app: " . $e->getMessage()); return back()->with('error', 'Failed to uninstall application: ' . $e->getMessage()); } } /** * Check if files remain in directory after deletion attempt */ private function hasFilesRemaining(HostingAccount $account, string $path): bool { $result = $this->runtimeFor($account)->executeCommand( $account, "ls -A " . escapeshellarg($path) . " 2>/dev/null | wc -l" ); return ((int) trim($result['output'] ?? '0')) > 0; } private function defaultSiteDocumentRoot(HostingAccount $account, HostedSite $site): string { return "/home/{$account->username}/public_html/{$site->domain}"; } private function hasAppSpecificDocumentRoot(?string $documentRoot, string $defaultDocRoot): bool { if (! $documentRoot) { return false; } $normalizedRoot = rtrim($documentRoot, '/'); $normalizedDefault = rtrim($defaultDocRoot, '/'); return in_array($normalizedRoot, [ "{$normalizedDefault}/public", "{$normalizedDefault}/pub", "{$normalizedDefault}/web", ], true); } private function installLaravel(HostingAccount $account, string $path): void { $result = $this->runtimeFor($account)->executeCommand( $account, "cd /home/{$account->username}/public_html && composer create-project laravel/laravel " . escapeshellarg(basename($path)) . " --prefer-dist 2>&1" ); if ($result['exit_code'] !== 0) { Log::error("Laravel install failed", ['output' => $result['output']]); throw new \RuntimeException( 'Laravel installation failed. ' . $this->formatInstallerCommandFailure('Create Laravel application', $result) ); } } private function installWordPress(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array { Log::info("Installing WordPress to: {$path}"); $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; if ($account->databases()->count() >= $maxDatabases) { throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); } $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); $dbName = $prefix . '_' . $dbSuffix; $dbUser = $dbName; $dbPassword = Str::password(16, true, true, false, false); $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; $adminPassword = Str::password(16, true, true, false, false); $siteTitle = $site->domain; $database = HostedDatabase::create([ 'hosting_account_id' => $account->id, 'hosted_site_id' => $site->id, 'name' => $dbName, 'username' => $dbUser, 'password_encrypted' => encrypt($dbPassword), 'type' => 'mysql', 'status' => 'active', ]); try { $this->runtimeFor($account)->createDatabase($database, $dbPassword); $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); $quotedPath = escapeshellarg($path); $probePath = escapeshellarg($path . '/.ladill_write_test'); $quotedInstallUrl = escapeshellarg($installUrl); $quotedSiteTitle = escapeshellarg($siteTitle); $quotedAdminUsername = escapeshellarg($adminUsername); $quotedAdminEmail = escapeshellarg($adminEmail); $quotedAdminPassword = escapeshellarg($adminPassword); $commands = [ 'Verify installation directory is writable' => "test -w {$quotedPath} && touch {$probePath} && rm -f {$probePath} 2>&1", 'Download WordPress package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://wordpress.org/latest.tar.gz -o latest.tar.gz 2>&1", 'Extract WordPress package' => "cd {$quotedPath} && tar -xzf latest.tar.gz --strip-components=1 2>&1", 'Remove installation archive' => "cd {$quotedPath} && rm -f latest.tar.gz 2>&1", 'Download WP-CLI' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o wp-cli.phar 2>&1", 'Create WordPress configuration' => "cd {$quotedPath} && php wp-cli.phar config create --dbname=" . escapeshellarg($dbName) . " --dbuser=" . escapeshellarg($dbUser) . " --dbpass=" . escapeshellarg($dbPassword) . " --dbhost=localhost --skip-check --force 2>&1", 'Install WordPress core' => "cd {$quotedPath} && php wp-cli.phar core install --url={$quotedInstallUrl} --title={$quotedSiteTitle} --admin_user={$quotedAdminUsername} --admin_password={$quotedAdminPassword} --admin_email={$quotedAdminEmail} --skip-email 2>&1", 'Set WordPress permalinks' => "cd {$quotedPath} && php wp-cli.phar rewrite structure '/%postname%/' 2>&1", 'Remove WP-CLI' => "cd {$quotedPath} && rm -f wp-cli.phar 2>&1", 'Verify WordPress files' => "test -f " . escapeshellarg($path . '/wp-load.php') . " && test -f " . escapeshellarg($path . '/wp-config.php') . " 2>&1", ]; foreach ($commands as $step => $cmd) { Log::info("Executing: {$cmd}"); $result = $this->runtimeFor($account)->executeCommand($account, $cmd); Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); if ($result['exit_code'] !== 0) { Log::error("WordPress install command failed: {$cmd}", [ 'step' => $step, 'exit_code' => $result['exit_code'], 'output' => $result['output'], ]); throw new \RuntimeException( 'WordPress installation failed. ' . $this->formatInstallerCommandFailure($step, $result) ); } } } catch (\Throwable $exception) { try { $this->runtimeFor($account)->deleteDatabase($database); } catch (\Throwable $cleanupException) { Log::warning('Failed to clean up WordPress database after install error', [ 'database' => $database->name, 'error' => $cleanupException->getMessage(), ]); } $database->delete(); throw $exception; } return [ 'db_name' => $dbName, 'db_user' => $dbUser, 'db_password' => $dbPassword, 'admin_url' => rtrim($installUrl, '/') . '/wp-admin', 'admin_path' => 'wp-admin', 'admin_user' => $adminUsername, 'admin_password' => $adminPassword, 'admin_email' => $adminEmail, 'app_version' => null, ]; } private function installJoomla(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array { Log::info("Installing Joomla to: {$path}"); $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; if ($account->databases()->count() >= $maxDatabases) { throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); } $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); $dbName = $prefix . '_' . $dbSuffix; $dbUser = $dbName; $dbPassword = Str::password(16, true, true, false, false); $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; $adminPassword = Str::password(16, true, true, false, false); $siteTitle = $site->domain; $database = HostedDatabase::create([ 'hosting_account_id' => $account->id, 'hosted_site_id' => $site->id, 'name' => $dbName, 'username' => $dbUser, 'password_encrypted' => encrypt($dbPassword), 'type' => 'mysql', 'status' => 'active', ]); try { $this->runtimeFor($account)->createDatabase($database, $dbPassword); $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); $quotedPath = escapeshellarg($path); $commands = [ 'Download Joomla package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://downloads.joomla.org/cms/joomla5/5-2-4/Joomla_5-2-4-Stable-Full_Package.zip -o joomla.zip 2>&1", 'Extract Joomla package' => "cd {$quotedPath} && unzip -q joomla.zip 2>&1", 'Remove installation archive' => "cd {$quotedPath} && rm -f joomla.zip 2>&1", 'Set directory permissions' => "cd {$quotedPath} && find . -type d -exec chmod 755 {} \\; && find . -type f -exec chmod 644 {} \\; 2>&1", 'Set writable directories' => "cd {$quotedPath} && chmod -R 775 cache tmp administrator/cache administrator/logs 2>&1", 'Set root writable for config' => "cd {$quotedPath} && chmod 775 . 2>&1", 'Initialize autoload cache' => "cd {$quotedPath} && echo ' administrator/cache/autoload_psr4.php && echo ' cache/autoload_psr4.php 2>&1", ]; foreach ($commands as $step => $cmd) { Log::info("Executing: {$cmd}"); $result = $this->runtimeFor($account)->executeCommand($account, $cmd); Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); if ($result['exit_code'] !== 0) { throw new \RuntimeException( 'Joomla installation failed. ' . $this->formatInstallerCommandFailure($step, $result) ); } } $this->runtimeFor($account)->setWebServerGroupOwnership($account, $path); } catch (\Throwable $exception) { try { $this->runtimeFor($account)->deleteDatabase($database); } catch (\Throwable $cleanupException) { Log::warning('Failed to clean up Joomla database after install error', ['error' => $cleanupException->getMessage()]); } $database->delete(); throw $exception; } return [ 'db_name' => $dbName, 'db_user' => $dbUser, 'db_password' => $dbPassword, 'admin_url' => rtrim($installUrl, '/') . '/administrator', 'admin_path' => 'administrator', 'admin_user' => $adminUsername, 'admin_password' => $adminPassword, 'admin_email' => $adminEmail, 'app_version' => '5.2.4', 'requires_setup' => true, ]; } private function installDrupal(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array { Log::info("Installing Drupal to: {$path}"); $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; if ($account->databases()->count() >= $maxDatabases) { throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); } $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); $dbName = $prefix . '_' . $dbSuffix; $dbUser = $dbName; $dbPassword = Str::password(16, true, true, false, false); $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; $adminPassword = Str::password(16, true, true, false, false); $database = HostedDatabase::create([ 'hosting_account_id' => $account->id, 'hosted_site_id' => $site->id, 'name' => $dbName, 'username' => $dbUser, 'password_encrypted' => encrypt($dbPassword), 'type' => 'mysql', 'status' => 'active', ]); try { $this->runtimeFor($account)->createDatabase($database, $dbPassword); $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); $quotedPath = escapeshellarg($path); $commands = [ 'Download Drupal package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://ftp.drupal.org/files/projects/drupal-11.1.1.tar.gz -o drupal.tar.gz 2>&1", 'Extract Drupal package' => "cd {$quotedPath} && tar -xzf drupal.tar.gz --strip-components=1 2>&1", 'Remove installation archive' => "cd {$quotedPath} && rm -f drupal.tar.gz 2>&1", 'Set directory permissions' => "cd {$quotedPath} && chmod -R 755 . 2>&1", ]; foreach ($commands as $step => $cmd) { Log::info("Executing: {$cmd}"); $result = $this->runtimeFor($account)->executeCommand($account, $cmd); Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); if ($result['exit_code'] !== 0) { throw new \RuntimeException( 'Drupal installation failed. ' . $this->formatInstallerCommandFailure($step, $result) ); } } } catch (\Throwable $exception) { try { $this->runtimeFor($account)->deleteDatabase($database); } catch (\Throwable $cleanupException) { Log::warning('Failed to clean up Drupal database after install error', ['error' => $cleanupException->getMessage()]); } $database->delete(); throw $exception; } return [ 'db_name' => $dbName, 'db_user' => $dbUser, 'db_password' => $dbPassword, 'admin_url' => rtrim($installUrl, '/') . '/admin', 'admin_path' => 'admin', 'admin_user' => $adminUsername, 'admin_password' => $adminPassword, 'admin_email' => $adminEmail, 'app_version' => '11.1.1', 'requires_setup' => true, ]; } private function installOpenCart(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array { Log::info("Installing OpenCart to: {$path}"); $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; if ($account->databases()->count() >= $maxDatabases) { throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); } $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); $dbName = $prefix . '_' . $dbSuffix; $dbUser = $dbName; $dbPassword = Str::password(16, true, true, false, false); $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; $adminPassword = Str::password(16, true, true, false, false); $database = HostedDatabase::create([ 'hosting_account_id' => $account->id, 'hosted_site_id' => $site->id, 'name' => $dbName, 'username' => $dbUser, 'password_encrypted' => encrypt($dbPassword), 'type' => 'mysql', 'status' => 'active', ]); try { $this->runtimeFor($account)->createDatabase($database, $dbPassword); $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); $quotedPath = escapeshellarg($path); $commands = [ 'Download OpenCart package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://github.com/opencart/opencart/releases/download/4.0.2.3/opencart-4.0.2.3.zip -o opencart.zip 2>&1", 'Extract OpenCart package' => "cd {$quotedPath} && unzip -q opencart.zip 2>&1", 'Move files to root' => "cd {$quotedPath} && mv upload/* . 2>&1 || true", 'Remove extra directories' => "cd {$quotedPath} && rm -rf upload opencart.zip 2>&1", 'Rename config files' => "cd {$quotedPath} && cp config-dist.php config.php && cp admin/config-dist.php admin/config.php 2>&1", 'Set directory permissions' => "cd {$quotedPath} && chmod -R 755 . 2>&1", ]; foreach ($commands as $step => $cmd) { Log::info("Executing: {$cmd}"); $result = $this->runtimeFor($account)->executeCommand($account, $cmd); Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); if ($result['exit_code'] !== 0 && !str_contains($step, 'Move files')) { throw new \RuntimeException( 'OpenCart installation failed. ' . $this->formatInstallerCommandFailure($step, $result) ); } } } catch (\Throwable $exception) { try { $this->runtimeFor($account)->deleteDatabase($database); } catch (\Throwable $cleanupException) { Log::warning('Failed to clean up OpenCart database after install error', ['error' => $cleanupException->getMessage()]); } $database->delete(); throw $exception; } return [ 'db_name' => $dbName, 'db_user' => $dbUser, 'db_password' => $dbPassword, 'admin_url' => rtrim($installUrl, '/') . '/admin', 'admin_path' => 'admin', 'admin_user' => $adminUsername, 'admin_password' => $adminPassword, 'admin_email' => $adminEmail, 'app_version' => '4.0.2.3', 'requires_setup' => true, ]; } private function installMagento(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array { Log::info("Installing Magento to: {$path}"); $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; if ($account->databases()->count() >= $maxDatabases) { throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); } $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); $dbName = $prefix . '_' . $dbSuffix; $dbUser = $dbName; $dbPassword = Str::password(16, true, true, false, false); $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; $adminPassword = Str::password(16, true, true, false, false); $database = HostedDatabase::create([ 'hosting_account_id' => $account->id, 'hosted_site_id' => $site->id, 'name' => $dbName, 'username' => $dbUser, 'password_encrypted' => encrypt($dbPassword), 'type' => 'mysql', 'status' => 'active', ]); try { $this->runtimeFor($account)->createDatabase($database, $dbPassword); $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); $quotedPath = escapeshellarg($path); $commands = [ 'Download Magento via Composer' => "cd {$quotedPath} && composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition . --no-interaction 2>&1 || curl -fLsS https://github.com/magento/magento2/archive/refs/tags/2.4.7.tar.gz -o magento.tar.gz && tar -xzf magento.tar.gz --strip-components=1 && rm -f magento.tar.gz 2>&1", 'Set directory permissions' => "cd {$quotedPath} && chmod -R 755 . 2>&1", ]; foreach ($commands as $step => $cmd) { Log::info("Executing: {$cmd}"); $result = $this->runtimeFor($account)->executeCommand($account, $cmd); Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); if ($result['exit_code'] !== 0) { throw new \RuntimeException( 'Magento installation failed. ' . $this->formatInstallerCommandFailure($step, $result) ); } } } catch (\Throwable $exception) { try { $this->runtimeFor($account)->deleteDatabase($database); } catch (\Throwable $cleanupException) { Log::warning('Failed to clean up Magento database after install error', ['error' => $cleanupException->getMessage()]); } $database->delete(); throw $exception; } return [ 'db_name' => $dbName, 'db_user' => $dbUser, 'db_password' => $dbPassword, 'admin_url' => rtrim($installUrl, '/') . '/admin', 'admin_path' => 'admin', 'admin_user' => $adminUsername, 'admin_password' => $adminPassword, 'admin_email' => $adminEmail, 'app_version' => '2.4.7', 'requires_setup' => true, ]; } private function phpMyAdminUrlForAccount(HostingAccount $account): ?string { $baseUrl = trim((string) config('hosting.shared.phpmyadmin_url', '')); if ($baseUrl === '') { return null; } // Support placeholders in the URL for per-account access $url = str_replace( ['{username}', '{account}', '{primary_domain}', '{node_ip}'], [ $account->username, $account->username, $account->primary_domain ?? '', $account->node?->ip_address ?? '', ], $baseUrl ); return rtrim($url, '/'); } private function formatInstallerCommandFailure(string $step, array $result): string { $output = trim((string) ($result['output'] ?? '')); if ($output !== '') { return "{$step} failed: {$output}"; } $exitCode = $result['exit_code'] ?? 'unknown'; return "{$step} failed with exit code {$exitCode}."; } private function installNodeJs(HostingAccount $account, string $path): void { $packageJson = json_encode([ 'name' => 'my-node-app', 'version' => '1.0.0', 'main' => 'app.js', 'scripts' => ['start' => 'node app.js'], ], JSON_PRETTY_PRINT); $appJs = "const http = require('http');\n\nconst server = http.createServer((req, res) => {\n res.writeHead(200, {'Content-Type': 'text/html'});\n res.end('

Hello from Node.js!

');\n});\n\nserver.listen(process.env.PORT || 3000);"; $result = $this->runtimeFor($account)->executeCommand($account, "mkdir -p " . escapeshellarg($path)); if ($result['exit_code'] !== 0) { throw new \RuntimeException("Failed to create directory"); } $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($packageJson) . " > " . escapeshellarg($path . "/package.json")); $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($appJs) . " > " . escapeshellarg($path . "/app.js")); } private function installPython(HostingAccount $account, string $path): void { $appPy = "from flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/')\ndef hello():\n return '

Hello from Python!

'\n\nif __name__ == '__main__':\n app.run()"; $requirements = "flask>=2.0.0\ngunicorn>=20.0.0"; $result = $this->runtimeFor($account)->executeCommand($account, "mkdir -p " . escapeshellarg($path)); if ($result['exit_code'] !== 0) { throw new \RuntimeException("Failed to create directory"); } $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($appPy) . " > " . escapeshellarg($path . "/app.py")); $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($requirements) . " > " . escapeshellarg($path . "/requirements.txt")); } // ==================== SETTINGS ==================== public function settings(Request $request, HostingAccount $account): View { $this->authorizeForRequestUser($request, 'viewSettings', $account); $account->load(['node', 'product']); $membership = $this->membershipForRequestUser($request, $account); return view('hosting.panel.settings', [ 'account' => $account, 'teamMembership' => $membership, 'canChangePassword' => Gate::forUser($request->user())->check('changePassword', $account), ]); } public function changePassword(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'changePassword', $account); $account->load(['node']); $validated = $request->validate([ 'password' => 'required|string|min:8|confirmed', ]); try { $this->runtimeFor($account)->changePassword($account, $validated['password']); return back()->with('success', 'Password changed successfully. Use this password for SFTP access.'); } catch (\Exception $e) { Log::error("Failed to change password: " . $e->getMessage()); return back()->with('error', 'Failed to change password. Please try again.'); } } public function saveSshKey(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'viewSettings', $account); $account->load(['node']); $membership = $this->requireDeveloperMembershipForRequestUser($request, $account); $validated = $request->validate([ 'ssh_public_key' => ['required', 'string', 'max:10000', function (string $attribute, mixed $value, \Closure $fail): void { if (! HostingAccountMember::looksLikeSshPublicKey((string) $value)) { $fail('Enter a valid OpenSSH public key.'); } }], ]); $sshPublicKey = HostingAccountMember::normalizeSshPublicKey($validated['ssh_public_key']); try { $this->runtimeFor($account)->installTeamMemberSshKey($account, $membership->sshKeyMarker(), $sshPublicKey); $membership->forceFill([ 'ssh_public_key' => $sshPublicKey, 'ssh_key_installed_at' => now(), ])->save(); return back()->with('success', 'SSH key saved. You can now connect over SFTP.'); } catch (\Throwable $e) { Log::error('Failed to save developer SSH key: '.$e->getMessage(), [ 'hosting_account_id' => $account->id, 'user_id' => $request->user()?->id, ]); return back()->with('error', 'Failed to save SSH key. Please try again.'); } } public function deleteSshKey(Request $request, HostingAccount $account): RedirectResponse { $this->authorizeForRequestUser($request, 'viewSettings', $account); $account->load(['node']); $membership = $this->requireDeveloperMembershipForRequestUser($request, $account); try { $this->runtimeFor($account)->removeTeamMemberSshKey($account, $membership->sshKeyMarker()); $membership->forceFill([ 'ssh_public_key' => null, 'ssh_key_installed_at' => null, ])->save(); return back()->with('success', 'SSH key removed. SFTP access through your team membership has been revoked.'); } catch (\Throwable $e) { Log::error('Failed to remove developer SSH key: '.$e->getMessage(), [ 'hosting_account_id' => $account->id, 'user_id' => $request->user()?->id, ]); return back()->with('error', 'Failed to remove SSH key. Please try again.'); } } // ==================== HELPERS ==================== private function runtimeFor(HostingAccount $account): PanelRuntimeInterface { return $this->panelRuntimeResolver->forSubject($account); } private function authorizeForRequestUser(Request $request, string $ability, HostingAccount $account): void { Gate::forUser($request->user())->authorize($ability, $account); } private function membershipForRequestUser(Request $request, HostingAccount $account): ?HostingAccountMember { $user = $request->user(); if (! $user || (int) $account->user_id === (int) $user->id) { return null; } return $account->teamMembers() ->where('user_id', $user->id) ->first(); } private function requireDeveloperMembershipForRequestUser(Request $request, HostingAccount $account): HostingAccountMember { $membership = $this->membershipForRequestUser($request, $account); abort_if(! $membership, 403); return $membership; } private function ensureDatabaseBelongsToAccount(HostedDatabase $database, HostingAccount $account): void { abort_if((int) $database->hosting_account_id !== (int) $account->id, 403); } private function ensureSiteBelongsToAccount(HostedSite $site, HostingAccount $account): void { abort_if((int) $site->hosting_account_id !== (int) $account->id, 403); } private function getAccountStats(HostingAccount $account): array { try { return $this->runtimeFor($account)->getUsageStats($account); } catch (\Exception $e) { Log::error("Failed to get account stats: " . $e->getMessage()); return [ 'disk_used_bytes' => 0, 'database_size_bytes' => 0, 'file_size_bytes' => 0, ]; } } private function guardWritableAccount(HostingAccount $account): ?JsonResponse { if ($account->status === HostingAccount::STATUS_SUSPENDED) { return response()->json([ 'success' => false, 'error' => 'This hosting account is suspended.', ], 423); } if ($account->uploadsAreRestricted()) { return response()->json([ 'success' => false, 'error' => 'Uploads are temporarily restricted because the inode limit has been reached.', ], 429); } return null; } private function listFiles(HostingAccount $account, string $path): array { $fullPath = "/home/{$account->username}{$path}"; $result = $this->runtimeFor($account)->executeCommand( $account, "ls -la " . escapeshellarg($fullPath) . " 2>/dev/null | tail -n +2" ); if ($result['exit_code'] !== 0) { return []; } $files = []; $lines = explode("\n", trim($result['output'])); foreach ($lines as $line) { if (empty($line)) continue; $parts = preg_split('/\s+/', $line, 9); if (count($parts) < 9) continue; $name = $parts[8]; if ($name === '.' || $name === '..') continue; $isDir = $parts[0][0] === 'd'; $permissions = $parts[0]; $size = (int) $parts[4]; $modified = "{$parts[5]} {$parts[6]} {$parts[7]}"; $files[] = [ 'name' => $name, 'path' => rtrim($path, '/') . '/' . $name, 'type' => $isDir ? 'folder' : 'file', 'size' => $size, 'size_formatted' => $this->formatBytes($size), 'permissions' => $permissions, 'modified' => $modified, 'extension' => $isDir ? null : pathinfo($name, PATHINFO_EXTENSION), ]; } usort($files, function ($a, $b) { if ($a['type'] !== $b['type']) { return $a['type'] === 'folder' ? -1 : 1; } return strcasecmp($a['name'], $b['name']); }); return $files; } private function sanitizePath(string $path): string { $path = '/' . ltrim($path, '/'); $path = preg_replace('#/\.\.#', '', $path); $path = preg_replace('#\.\./#', '', $path); $path = preg_replace('#/+#', '/', $path); if ($path === '/') { return '/public_html'; } return $path; } private function resolveTerminalPath(string $path, string $currentPath = '/public_html'): string { $path = trim($path); $currentPath = trim($currentPath); if ($currentPath === '') { $currentPath = '/public_html'; } if ($path === '') { return $currentPath; } if ($path === '~') { return '/'; } if (str_starts_with($path, '~/')) { $path = substr($path, 1); } $candidate = str_starts_with($path, '/') ? $path : rtrim($currentPath, '/').'/'.$path; $segments = explode('/', $candidate); $normalized = []; foreach ($segments as $segment) { if ($segment === '' || $segment === '.') { continue; } if ($segment === '..') { array_pop($normalized); continue; } $normalized[] = $segment; } $resolvedPath = '/'.implode('/', $normalized); return $resolvedPath === '' ? '/' : $resolvedPath; } private function terminalWorkingDirectory(HostingAccount $account, string $path): string { $relativePath = $this->resolveTerminalPath($path); return $relativePath === '/' ? "/home/{$account->username}" : "/home/{$account->username}{$relativePath}"; } private function assertTerminalDirectoryExists(HostingAccount $account, string $path): void { $fullPath = $this->terminalWorkingDirectory($account, $path); $result = $this->runtimeFor($account)->executeCommand( $account, '[ -d '.escapeshellarg($fullPath).' ] && printf yes || printf no' ); if (($result['exit_code'] ?? 1) !== 0 || trim((string) ($result['output'] ?? '')) !== 'yes') { throw new \RuntimeException('Directory does not exist.'); } } private function getBreadcrumbs(string $path): array { $parts = explode('/', trim($path, '/')); $breadcrumbs = [['name' => 'Home', 'path' => '/public_html']]; if ($path === '/') { return [['name' => 'Home', 'path' => '/']]; } if (! str_starts_with($path, '/public_html')) { $breadcrumbs = [['name' => 'Home', 'path' => '/']]; } $currentPath = ''; foreach ($parts as $part) { if (empty($part)) continue; $currentPath .= '/' . $part; $breadcrumbs[] = ['name' => $part, 'path' => $currentPath]; } return $breadcrumbs; } private function formatBytes(int $bytes): string { if ($bytes >= 1073741824) { return number_format($bytes / 1073741824, 2) . ' GB'; } elseif ($bytes >= 1048576) { return number_format($bytes / 1048576, 2) . ' MB'; } elseif ($bytes >= 1024) { return number_format($bytes / 1024, 2) . ' KB'; } return $bytes . ' B'; } }