Deploy Ladill Hosting / deploy (push) Successful in 51s
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>
2905 lines
114 KiB
PHP
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';
|
|
}
|
|
}
|