Files
ladill-servers/app/Http/Controllers/Hosting/HostingPanelController.php
T
isaaccladandCursor 942d996f12
Deploy Ladill Servers / deploy (push) Successful in 27s
Wire Ladill domain picker and purchase modal into servers flows.
Use the Domains API for owned domains across panel, account, and order forms, with an embedded iframe purchase modal instead of redirecting to domains.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:01:23 +00:00

2905 lines
114 KiB
PHP

<?php
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use App\Jobs\ProvisionHostingSslJob;
use App\Models\AppInstallation;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\HostedDatabase;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingAccountMember;
use App\Models\HostingOrder;
use App\Models\RcServiceOrder;
use App\Models\Website;
use App\Models\WebsiteMember;
use App\Services\Domain\DomainDnsBlueprintService;
use App\Services\Domain\DomainVerificationService;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
use App\Services\Hosting\PanelRuntimeResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\View\View;
class HostingPanelController extends Controller
{
private const ARCHIVE_COMMAND_TIMEOUT = 1800;
private const TERMINAL_ALLOWED_EXECUTABLES = [
'php',
'composer',
'artisan',
'phpunit',
'pest',
'pint',
'git',
'node',
'npm',
'npx',
'yarn',
'pnpm',
'python',
'python3',
'pip',
'pip3',
'wp',
'mysql',
'mysqldump',
'ls',
'pwd',
'cat',
'head',
'tail',
'grep',
'find',
'whoami',
'which',
'env',
];
public function __construct(
private PanelRuntimeResolver $panelRuntimeResolver
) {}
public function index(Request $request, HostingAccount $account): View
{
$this->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);
if ($isOwnedDomain) {
$owned = $this->availableHostingDomainsForUser($request->user()->id, $account);
if (! $owned->contains($domainHost)) {
if ($request->wantsJson()) {
return response()->json(['message' => 'That domain is not in your Ladill account.'], 422);
}
return back()->with('error', 'That domain is not in your Ladill account.');
}
}
$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();
$user = \App\Models\User::query()->find($userId);
$owned = $user
? app(\App\Services\Domains\LadillDomainsClient::class)->ownedForUser((string) $user->public_id)
: [];
return collect($owned)
->reject(fn ($d) => in_array($d, $linkedDomains, true))
->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 '<?php return [];' > administrator/cache/autoload_psr4.php && echo '<?php return [];' > 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('<h1>Hello from Node.js!</h1>');\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 '<h1>Hello from Python!</h1>'\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';
}
}