From 3d86f67acb73dad9e2fccfd6dc1b7c6a302afcc7 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 22 Jan 2026 19:52:46 +0530 Subject: [PATCH] update: move server handling to a new file. update: tooltip things. remove: unused var. --- app/views/install/compose.phtml | 1 - app/views/install/installer.phtml | 4 + app/views/install/installer/css/styles.css | 125 ++- app/views/install/installer/js/constants.js | 3 +- app/views/install/installer/js/steps.js | 75 +- app/views/install/installer/js/tooltips.js | 77 ++ .../installer/templates/steps/step-1.phtml | 28 +- app/views/install/router.php | 13 +- composer.json | 4 +- src/Appwrite/Platform/Installer/Server.php | 767 +++++++++++++++ src/Appwrite/Platform/Tasks/Install.php | 877 ++---------------- 11 files changed, 1059 insertions(+), 915 deletions(-) create mode 100644 app/views/install/installer/js/tooltips.js create mode 100644 src/Appwrite/Platform/Installer/Server.php diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index a934769713..91d6b33cfb 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -12,7 +12,6 @@ $version = $this->getParam('version', ''); $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $dbService = $this->getParam('database'); -$hostPath = $this->getParam('hostPath', ''); ?>services: traefik: diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index a5d08b72d3..69146b9f00 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -7,10 +7,12 @@ $vars = $vars ?? []; $defaultHttpPort = $defaultHttpPort ?? '80'; $defaultHttpsPort = $defaultHttpsPort ?? '443'; $defaultAppDomain = $vars['_APP_DOMAIN']['default'] ?? 'localhost'; +$defaultAppDomain = ($defaultAppDomain === 'traefik') ? 'localhost' : $defaultAppDomain; $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? ''; $defaultSecretKey = $vars['_APP_OPENSSL_KEY_V1']['default'] ?? ''; $defaultAssistantOpenAIKey = $vars['_APP_ASSISTANT_OPENAI_API_KEY']['default'] ?? ''; $defaultDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? 'mongodb'; +$lockedDatabase = $isUpgrade && empty($lockedDatabase) ? $defaultDatabase : $lockedDatabase; $isLocalInstall = !empty(getenv('APPWRITE_INSTALLER_LOCAL')); if ($installMode === 'mock' && $defaultEmailCertificates === '') { $defaultEmailCertificates = 'walterobrien@example.com'; @@ -38,6 +40,7 @@ if (!is_file($stepFile)) { $stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml"; } $cssVersion = @filemtime(__DIR__ . '/installer/css/styles.css') ?: time(); +$tooltipsVersion = @filemtime(__DIR__ . '/installer/js/tooltips.js') ?: time(); $constantsVersion = @filemtime(__DIR__ . '/installer/js/constants.js') ?: time(); $stepsVersion = @filemtime(__DIR__ . '/installer/js/steps.js') ?: time(); $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time(); @@ -50,6 +53,7 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() <?php echo $isUpgrade ? 'Appwrite Update' : 'Appwrite Installation'; ?> + diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index 4114483c70..9c4ac1c66f 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -145,6 +145,7 @@ /* Border radius */ --border-radius-s: 8px; + --border-radius-xs: 6px; --border-radius-m: 12px; --border-radius-l: 16px; @@ -263,57 +264,14 @@ body { opacity: 0.4; } -.step-layout.is-locked .selector-card, -.step-layout.is-locked .accordion-toggle { - cursor: not-allowed; -} - -.step-layout.is-locked .selector-card { - opacity: 0.6; -} - .selector-card.is-disabled { - opacity: 0.6; - cursor: not-allowed; - pointer-events: none; + cursor: default; + pointer-events: auto; } -.selector-group.is-locked::after { - content: ''; - position: absolute; - inset: 0; - background: rgba(250, 250, 251, 0.8); - border-radius: var(--border-radius-s); - pointer-events: none; - z-index: 1; -} - -.selector-lock { - position: absolute; - inset: 0; - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--gap-xs); - color: var(--fgcolor-neutral-secondary); - z-index: 2; - pointer-events: none; -} - -.selector-lock svg { - width: 16px; - height: 16px; - display: block; -} - -.selector-lock { - padding: var(--space-2) var(--space-4); - border-radius: var(--border-radius-s); - border: var(--border-width-s) solid var(--border-neutral); - background: rgba(255, 255, 255, 0.95); - width: max-content; - height: max-content; - margin: auto; +.selector-card.is-disabled .selector-content, +.selector-card.is-disabled .selector-icon { + opacity: 0.4; } .installer-step { @@ -473,7 +431,7 @@ body { flex-direction: column; align-items: stretch; gap: var(--gap-xxxs); - transition: height var(--duration-medium) var(--ease-standard); + transition: height var(--duration-slow) var(--ease-standard); } .install-header { @@ -503,7 +461,7 @@ body { opacity: 1; transform: translateY(0); transition: opacity var(--duration-medium) var(--ease-standard), - transform var(--duration-slow) var(--ease-emphasized); + transform var(--duration-extended) var(--ease-standard); will-change: transform, opacity; } @@ -580,7 +538,7 @@ body { .install-row.is-entering { opacity: 0; - transform: translateY(-12px); + transform: translateY(-8px); } .install-icon { @@ -949,6 +907,7 @@ body { gap: var(--gap-m); width: 100%; position: relative; + overflow: visible; } .selector-card { @@ -968,6 +927,10 @@ body { transition: all var(--duration-fast) var(--ease-in-out); } +.selector-card.has-tooltip { + overflow: visible; +} + .selector-card::before { content: ''; position: absolute; @@ -982,6 +945,49 @@ body { opacity: 1; } +.selector-card.is-disabled::before, +.selector-card.is-disabled:hover::before { + opacity: 0; +} + +.selector-group.is-locked .selector-card { + cursor: default; +} + +.selector-group.is-locked .selector-card:hover::before { + opacity: 0; +} + +.selector-card.has-tooltip .tooltip { + top: calc(100% + 6px); + bottom: auto; + transform: translateX(-50%) translateY(-8px); +} + +.selector-card.has-tooltip { + z-index: 0; +} + +.selector-card.has-tooltip:hover { + z-index: 3; +} + +.selector-card.has-tooltip:hover .tooltip, +.selector-card.has-tooltip:focus-within .tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.tooltip-db-locked { + width: 193px; + max-width: none; + min-width: 193px; + text-align: center; +} .selector-card.selected { box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger); } @@ -1303,6 +1309,23 @@ body { z-index: 5; } +.tooltip.is-open { + opacity: 1; + visibility: visible; + transform: translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.tooltip-portal { + position: fixed; + left: 0; + top: 0; + bottom: auto; + transform: translateY(8px); +} + .tooltip-assistant { width: 246px; max-width: 246px; diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js index 0718aae7a1..06fd531a6d 100644 --- a/app/views/install/installer/js/constants.js +++ b/app/views/install/installer/js/constants.js @@ -6,7 +6,8 @@ installFallbackDelayMs: 12000, redirectDelayMs: 500, mockStepDelayMs: 1800, - progressTransitionDelayMs: 200, + progressTransitionDelayMs: 320, + progressCompleteDelayMs: 140, mockErrorDetails: { output: [ 'Failed to start containers: appwrite-worker-webhooks', diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index c8cd07a55e..9fdd06fd08 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -65,7 +65,8 @@ installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, redirectDelay: CONSTANTS.redirectDelayMs ?? 500, mockStepDelay: CONSTANTS.mockStepDelayMs ?? 1800, - progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140 + progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140, + progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120 }; const formState = { @@ -423,21 +424,20 @@ }; const lockDatabaseSelection = (root, lockedDatabase) => { - const cards = root.querySelectorAll('.selector-card'); - cards.forEach((card) => { - card.classList.add('is-disabled'); - const input = card.querySelector('input[name="database"]'); - if (input) { - input.disabled = true; - } - }); - if (lockedDatabase) { - const radio = root.querySelector(`input[name="database"][value="${lockedDatabase}"]`); - if (radio) { - radio.checked = true; - updateDatabaseSelection(radio, root); - } + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + const isLockedChoice = radio.value === lockedDatabase; + const card = radio.closest('.selector-card'); + radio.disabled = !isLockedChoice; + if (card) { + card.classList.toggle('is-disabled', !isLockedChoice); + } + if (isLockedChoice) { + radio.checked = true; + updateDatabaseSelection(radio, root); + } + }); } }; @@ -548,15 +548,6 @@ bindDatabaseSelection(root); } - if (isUpgradeMode()) { - const radios = root.querySelectorAll('input[name="database"]'); - radios.forEach((radio) => { - radio.checked = false; - }); - const cards = root.querySelectorAll('.selector-card'); - cards.forEach((card) => card.classList.remove('selected')); - } - const hostname = root.querySelector('#hostname'); const httpPort = root.querySelector('#http-port'); const httpsPort = root.querySelector('#https-port'); @@ -582,6 +573,7 @@ setupResetButtons(root); setupAccordion(root); + Tooltips?.setupTooltipPortals?.(root); }; const generateSecretKey = () => { @@ -631,6 +623,8 @@ setTooltipText(wrapper, defaultText); }; + const Tooltips = window.InstallerTooltips || null; + const hydrateStep2State = (root) => { const value = root.querySelector('#secret-key')?.value; if (formState.opensslKey) return; @@ -911,12 +905,15 @@ const hasPort = rawDomain.includes(':') || rawDomain.startsWith('['); let host = rawDomain; const hostForProtocol = extractHostname(rawDomain); + const normalizedHost = hostForProtocol.toLowerCase(); if (hostForProtocol === '0.0.0.0') { host = rawDomain.replace('0.0.0.0', 'localhost'); + } else if (normalizedHost === 'traefik') { + host = rawDomain.replace(hostForProtocol, 'localhost'); } let protocol = 'http'; let port = httpPort; - if (httpsPort && httpsPort !== '0' && !isLocalHost(hostForProtocol)) { + if (httpsPort && httpsPort !== '0' && !isLocalHost(normalizedHost)) { protocol = 'https'; port = httpsPort; } @@ -1071,6 +1068,7 @@ const progressState = new Map(); let pendingProgressTimer = null; let pendingProgressStep = null; + let pendingCompletionTimer = null; syncInstallLockFlag(); applyLockPayload(); applyBodyDefaults(); @@ -1199,6 +1197,10 @@ pendingProgressTimer = null; pendingProgressStep = null; } + if (pendingCompletionTimer) { + clearTimeout(pendingCompletionTimer); + pendingCompletionTimer = null; + } const step = getStepDefinition(payload.step) || { id: payload.step, inProgress: payload.message || payload.step, @@ -1206,24 +1208,32 @@ }; if (payload.status === 'in-progress') { const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id); - let didAutoComplete = false; + const completionTargets = []; if (currentIndex > 0) { for (let i = 0; i < currentIndex; i += 1) { const previousStep = INSTALLATION_STEPS[i]; const previousState = progressState.get(previousStep.id); if (previousState && previousState.status !== 'completed') { - progressState.set(previousStep.id, { - status: 'completed', - message: previousStep.done, + completionTargets.push({ + step: previousStep, details: previousState.details }); - didAutoComplete = true; } } } - if (didAutoComplete && currentIndex > 0) { - renderProgress(); + if (completionTargets.length && currentIndex > 0) { pendingProgressStep = payload.step; + pendingCompletionTimer = setTimeout(() => { + pendingCompletionTimer = null; + completionTargets.forEach(({ step: previousStep, details }) => { + progressState.set(previousStep.id, { + status: 'completed', + message: previousStep.done, + details + }); + }); + renderProgress(); + }, TIMINGS.progressCompleteDelay); pendingProgressTimer = setTimeout(() => { pendingProgressTimer = null; pendingProgressStep = null; @@ -1558,6 +1568,7 @@ if (!container) return; const root = container.querySelector('.step-layout') || container; const normalized = clampStep(step); + Tooltips?.cleanupTooltipPortals?.(); if (normalized !== 3 && reviewListener) { document.removeEventListener('installer:state-change', reviewListener); reviewListener = null; diff --git a/app/views/install/installer/js/tooltips.js b/app/views/install/installer/js/tooltips.js new file mode 100644 index 0000000000..96b637b9a3 --- /dev/null +++ b/app/views/install/installer/js/tooltips.js @@ -0,0 +1,77 @@ +(() => { + const tooltipPortals = new Set(); + + const positionTooltipPortal = (tooltip, anchor) => { + if (!tooltip || !anchor) return; + const rect = anchor.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const offset = Number(tooltip.dataset.tooltipOffset || 6); + const padding = 8; + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding)); + const top = rect.bottom + offset; + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + }; + + const attachTooltipPortal = (tooltip) => { + if (!tooltip || tooltip.dataset.portalInitialized === 'true') return; + const anchor = tooltip.parentElement; + if (!anchor) return; + + tooltip.dataset.portalInitialized = 'true'; + tooltip.classList.add('tooltip-portal'); + document.body.appendChild(tooltip); + + const show = () => { + tooltip.classList.add('is-open'); + positionTooltipPortal(tooltip, anchor); + }; + const hide = () => { + tooltip.classList.remove('is-open'); + }; + const refresh = () => { + if (tooltip.classList.contains('is-open')) { + positionTooltipPortal(tooltip, anchor); + } + }; + + anchor.addEventListener('mouseenter', show); + anchor.addEventListener('mouseleave', hide); + anchor.addEventListener('focusin', show); + anchor.addEventListener('focusout', hide); + window.addEventListener('scroll', refresh, true); + window.addEventListener('resize', refresh); + + tooltipPortals.add({ + tooltip, + cleanup: () => { + anchor.removeEventListener('mouseenter', show); + anchor.removeEventListener('mouseleave', hide); + anchor.removeEventListener('focusin', show); + anchor.removeEventListener('focusout', hide); + window.removeEventListener('scroll', refresh, true); + window.removeEventListener('resize', refresh); + if (tooltip.parentElement) { + tooltip.parentElement.removeChild(tooltip); + } + } + }); + }; + + const setupTooltipPortals = (root) => { + if (!root) return; + const portalTooltips = root.querySelectorAll('.tooltip[data-tooltip-portal]'); + portalTooltips.forEach((tooltip) => attachTooltipPortal(tooltip)); + }; + + const cleanupTooltipPortals = () => { + tooltipPortals.forEach((entry) => entry.cleanup()); + tooltipPortals.clear(); + }; + + window.InstallerTooltips = { + setupTooltipPortals, + cleanupTooltipPortals + }; +})(); diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml index c1a036ddf1..0b331b8e02 100644 --- a/app/views/install/installer/templates/steps/step-1.phtml +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -7,7 +7,10 @@ $defaultHttpsPort = $defaultHttpsPort ?? '443'; $defaultEmailCertificates = $defaultEmailCertificates ?? ''; $defaultAssistantOpenAIKey = $defaultAssistantOpenAIKey ?? ''; $defaultDatabase = $defaultDatabase ?? 'mongodb'; -$selectedDatabase = $isUpgrade ? '' : ($lockedDatabase ?: $defaultDatabase); +$selectedDatabase = $lockedDatabase ?: $defaultDatabase; +$isDatabaseLocked = !empty($lockedDatabase); +$mongoDisabled = $isDatabaseLocked && $selectedDatabase !== 'mongodb'; +$mariaDisabled = $isDatabaseLocked && $selectedDatabase !== 'mariadb'; $hostnameValue = htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); $httpPortValue = htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); $httpsPortValue = htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); @@ -38,31 +41,30 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey,
-
-
diff --git a/app/views/install/router.php b/app/views/install/router.php index e615f43652..8d48f15c8c 100644 --- a/app/views/install/router.php +++ b/app/views/install/router.php @@ -1,7 +1,10 @@ INSTALL_LOCK_TTL) { + return false; + } + + return true; +} + +function withGlobalLock(callable $callback) +{ + $path = globalLockPath(); + $handle = fopen($path, 'c+'); + if ($handle === false) { + return $callback(null, null); + } + if (!flock($handle, LOCK_EX)) { + fclose($handle); + return $callback(null, null); + } + + $contents = stream_get_contents($handle); + $lock = null; + if ($contents !== false && $contents !== '') { + $decoded = json_decode($contents, true); + if (is_array($decoded)) { + $lock = $decoded; + } + } + + $result = $callback($handle, $lock); + + fflush($handle); + flock($handle, LOCK_UN); + fclose($handle); + + return $result; +} + +function reserveGlobalLock(string $installId): string +{ + return (string) withGlobalLock(function ($handle, $lock) use ($installId) { + if (!$handle) { + return 'unavailable'; + } + if (isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + return 'locked'; + } + $payload = [ + 'installId' => $installId, + 'status' => 'in-progress', + 'updatedAt' => time(), + ]; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($payload)); + return 'ok'; + }); +} + +function updateGlobalLock(string $installId, string $status): void +{ + withGlobalLock(function ($handle, $lock) use ($installId, $status) { + if (!$handle) { + return; + } + if (isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + return; + } + $payload = [ + 'installId' => $installId, + 'status' => $status, + 'updatedAt' => time(), + ]; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($payload)); + }); +} + +function readProgressFile(string $installId): array +{ + $path = progressFilePath($installId); + if (!file_exists($path)) { + return [ + 'installId' => $installId, + 'steps' => [], + ]; + } + + $contents = file_get_contents($path); + if ($contents === false) { + return [ + 'installId' => $installId, + 'steps' => [], + ]; + } + + $data = json_decode($contents, true); + if (!is_array($data)) { + return [ + 'installId' => $installId, + 'steps' => [], + ]; + } + + return $data; +} + +function writeProgressFile(string $installId, array $payload): void +{ + $data = readProgressFile($installId); + if (!isset($data['steps']) || !is_array($data['steps'])) { + $data['steps'] = []; + } + + if (!empty($payload['step'])) { + $data['steps'][$payload['step']] = [ + 'status' => $payload['status'] ?? 'in-progress', + 'message' => $payload['message'] ?? '', + 'updatedAt' => $payload['updatedAt'] ?? time(), + ]; + } + + if (!empty($payload['status']) && $payload['status'] === 'error') { + $data['error'] = $payload['message'] ?? 'Installation failed'; + } + + if (isset($payload['details']) && is_array($payload['details'])) { + $data['details'][$payload['step']] = $payload['details']; + } + + if (isset($payload['payload']) && is_array($payload['payload'])) { + $data['payload'] = $payload['payload']; + if (!isset($data['startedAt'])) { + $data['startedAt'] = $payload['updatedAt'] ?? time(); + } + } + + $data['updatedAt'] = $payload['updatedAt'] ?? time(); + + file_put_contents(progressFilePath($installId), json_encode($data)); +} + +function sendSseEvent(string $event, array $payload): void +{ + echo "event: {$event}\n"; + echo 'data: ' . json_encode($payload) . "\n\n"; + @ob_flush(); + @flush(); +} + +function respondBadRequest(string $message, bool $wantsStream): void +{ + if ($wantsStream) { + sendSseEvent('error', ['message' => $message, 'step' => 'config-files']); + } else { + http_response_code(400); + echo json_encode(['success' => false, 'message' => $message]); + } + exit; +} + +function hashSensitiveValue(string $value): string +{ + $trimmed = trim($value); + if ($trimmed === '') { + return ''; + } + return hash('sha256', $trimmed); +} + +function isValidPort($value): bool +{ + $string = (string) $value; + if ($string === '' || !preg_match('/^\d+$/', $string)) { + return false; + } + $port = (int) $string; + return $port >= 1 && $port <= 65535; +} + +function isValidEmailAddress(string $value): bool +{ + return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; +} + +function isValidAppDomain(string $value): bool +{ + if ($value === 'localhost') { + return true; + } + if (filter_var($value, FILTER_VALIDATE_IP) !== false) { + return true; + } + return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; +} + +function isValidAppDomainInput(string $value): bool +{ + $value = trim($value); + if ($value === '') { + return false; + } + + $host = $value; + $port = null; + + if (str_starts_with($value, '[')) { + if (!preg_match('/^\[(.+)\](?::(\d+))?$/', $value, $matches)) { + return false; + } + $host = $matches[1] ?? ''; + $port = $matches[2] ?? null; + } else { + $parts = explode(':', $value); + if (count($parts) > 2) { + return false; + } + if (count($parts) === 2) { + [$host, $port] = $parts; + } + } + + if ($port !== null && $port !== '' && !isValidPort($port)) { + return false; + } + + return isValidAppDomain($host); +} + +function isValidDatabaseAdapter(string $value): bool +{ + return in_array($value, ['mongodb', 'mariadb'], true); +} + +function sendInstallerHtmlHeaders(): void +{ + header('Content-Type: text/html; charset=utf-8'); + header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"); +} + +// Serve static files +if ($uri !== '/' && $uri !== '') { + $requestPath = $uri; + $publicBase = realpath(PUBLIC_PATH); + $viewsBase = realpath(APP_VIEWS_PATH); + $filePath = null; + + if ($publicBase !== false) { + $candidate = realpath($publicBase . $requestPath); + if ($candidate !== false && str_starts_with($candidate, $publicBase . DIRECTORY_SEPARATOR)) { + $filePath = $candidate; + } + } + + if (($filePath === null || !is_file($filePath)) && str_starts_with($uri, '/installer/') && $viewsBase !== false) { + $candidate = realpath($viewsBase . $requestPath); + if ($candidate !== false && str_starts_with($candidate, $viewsBase . DIRECTORY_SEPARATOR)) { + $filePath = $candidate; + } + } + + if ($filePath && is_file($filePath)) { + // Determine content type + $extension = pathinfo($filePath, PATHINFO_EXTENSION); + $mimeTypes = [ + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + ]; + + $contentType = $mimeTypes[$extension] ?? 'application/octet-stream'; + header('Content-Type: ' . $contentType); + header('Content-Length: ' . filesize($filePath)); + readfile($filePath); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET' && $uri === '/install/status') { + header('Content-Type: application/json'); + + $installId = sanitizeInstallId($_GET['installId'] ?? ''); + if ($installId === '') { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Missing installId']); + exit; + } + + $path = progressFilePath($installId); + if (!file_exists($path)) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'Install not found']); + exit; + } + + $data = readProgressFile($installId); + echo json_encode(['success' => true, 'progress' => $data]); + exit; +} + +// Handle POST request (completion) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install/complete') { + header('Content-Type: application/json'); + + $input = json_decode(file_get_contents('php://input'), true); + $installId = sanitizeInstallId($input['installId'] ?? ''); + + if ($installId !== '') { + updateGlobalLock($installId, 'completed'); + } + + echo json_encode(['success' => true]); + + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } + + if (function_exists('posix_getpid')) { + $pid = posix_getpid(); + if ($pid) { + $delay = 5; + $command = 'sh -c ' . escapeshellarg("sleep {$delay}; kill {$pid} >/dev/null 2>&1"); + @exec($command . ' >/dev/null 2>&1 &'); + } + } + + exit; +} + +// Handle POST request (installation) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { + $acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? ''; + $acceptsStream = stripos($acceptHeader, 'text/event-stream') !== false; + $wantsStream = $acceptsStream; + + if ($wantsStream) { + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + @ini_set('output_buffering', 'off'); + @ini_set('zlib.output_compression', '0'); + @ini_set('implicit_flush', '1'); + if (function_exists('ob_get_level')) { + while (ob_get_level() > 0) { + @ob_end_flush(); + } + } + @ob_implicit_flush(true); + } else { + header('Content-Type: application/json'); + } + + $input = json_decode(file_get_contents('php://input'), true); + + if (!$input) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid request']); + exit; + } + + $appDomain = trim((string) ($input['appDomain'] ?? '')); + if ($appDomain === '' || !isValidAppDomainInput($appDomain)) { + respondBadRequest('Please enter a valid hostname', $wantsStream); + } + $input['appDomain'] = $appDomain; + + $httpPort = $input['httpPort'] ?? ''; + if (!isValidPort($httpPort)) { + respondBadRequest('Please enter a valid HTTP port (1-65535)', $wantsStream); + } + + $httpsPort = $input['httpsPort'] ?? ''; + if (!isValidPort($httpsPort)) { + respondBadRequest('Please enter a valid HTTPS port (1-65535)', $wantsStream); + } + + $emailCertificates = trim((string) ($input['emailCertificates'] ?? '')); + if ($emailCertificates === '' || !isValidEmailAddress($emailCertificates)) { + respondBadRequest('Please enter a valid email address', $wantsStream); + } + $input['emailCertificates'] = $emailCertificates; + + $opensslKey = trim((string) ($input['opensslKey'] ?? '')); + if ($opensslKey === '' || strlen($opensslKey) > 64) { + respondBadRequest('Secret API key must be 1-64 characters', $wantsStream); + } + $input['opensslKey'] = $opensslKey; + + $assistantOpenAIKey = trim((string) ($input['assistantOpenAIKey'] ?? '')); + $input['assistantOpenAIKey'] = $assistantOpenAIKey; + + if (!LOCKED_DATABASE) { + $database = strtolower(trim((string) ($input['database'] ?? ''))); + if (!isValidDatabaseAdapter($database)) { + respondBadRequest('Please select a supported database', $wantsStream); + } + $input['database'] = $database; + } + + $installId = sanitizeInstallId($input['installId'] ?? ''); + if ($installId === '') { + $installId = bin2hex(random_bytes(8)); + } + + $lockResult = reserveGlobalLock($installId); + if ($lockResult !== 'ok') { + if ($lockResult === 'locked') { + http_response_code(409); + echo json_encode(['success' => false, 'message' => 'Installation already in progress']); + } else { + http_response_code(503); + echo json_encode(['success' => false, 'message' => 'Installer lock unavailable']); + } + exit; + } + + $retryStep = $input['retryStep'] ?? null; + $allowedRetrySteps = ['docker-compose', 'env-vars', 'docker-containers']; + if (!is_string($retryStep) || !in_array($retryStep, $allowedRetrySteps, true)) { + $retryStep = null; + } + + $existingPath = progressFilePath($installId); + $existing = null; + if (file_exists($existingPath)) { + $existing = readProgressFile($installId); + if (!empty($existing['steps']) && $retryStep === null) { + http_response_code(409); + echo json_encode(['success' => false, 'message' => 'Installation already started']); + exit; + } + } + + try { + ignore_user_abort(true); + require_once INSTALL_PHP_PATH; + $installer = new \Appwrite\Platform\Tasks\Install(); + + if ($wantsStream) { + sendSseEvent('install-id', ['installId' => $installId]); + } + + updateGlobalLock($installId, 'in-progress'); + + // Prepare user inputs - use locked database if in upgrade mode + $payloadInput = [ + '_APP_ENV' => 'production', + '_APP_OPENSSL_KEY_V1' => $input['opensslKey'] ?? '', + '_APP_DOMAIN' => $input['appDomain'] ?? 'localhost', + '_APP_DOMAIN_TARGET' => $input['appDomain'] ?? 'localhost', + '_APP_EMAIL_CERTIFICATES' => $input['emailCertificates'] ?? '', + '_APP_DB_ADAPTER' => LOCKED_DATABASE ?? ($input['database'] ?? 'mongodb'), + '_APP_ASSISTANT_OPENAI_API_KEY' => $input['assistantOpenAIKey'] ?? '', + ]; + + if (is_array($existing) && isset($existing['payload']) && is_array($existing['payload'])) { + $stored = $existing['payload']; + $fieldsToCompare = [ + 'httpPort', + 'httpsPort', + 'database', + 'appDomain', + 'emailCertificates', + ]; + foreach ($fieldsToCompare as $field) { + if (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + respondBadRequest('Installation payload mismatch', $wantsStream); + } + } + + $sensitiveFields = [ + 'opensslKey' => 'opensslKeyHash', + 'assistantOpenAIKey' => 'assistantOpenAIKeyHash', + ]; + foreach ($sensitiveFields as $field => $hashField) { + if (!isset($stored[$hashField]) && !isset($stored[$field])) { + continue; + } + $incomingHash = hashSensitiveValue((string) ($input[$field] ?? '')); + if (isset($stored[$hashField])) { + if ((string) $stored[$hashField] !== $incomingHash) { + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + respondBadRequest('Installation payload mismatch', $wantsStream); + } + } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + respondBadRequest('Installation payload mismatch', $wantsStream); + } + } + + $payloadInput['_APP_DOMAIN'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN']; + $payloadInput['_APP_DOMAIN_TARGET'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN_TARGET']; + $payloadInput['_APP_EMAIL_CERTIFICATES'] = $stored['emailCertificates'] ?? $payloadInput['_APP_EMAIL_CERTIFICATES']; + $payloadInput['_APP_DB_ADAPTER'] = LOCKED_DATABASE ?? ($stored['database'] ?? $payloadInput['_APP_DB_ADAPTER']); + $input['httpPort'] = $stored['httpPort'] ?? $input['httpPort'] ?? DEFAULT_HTTP_PORT; + $input['httpsPort'] = $stored['httpsPort'] ?? $input['httpsPort'] ?? DEFAULT_HTTPS_PORT; + } + + // Use the prepareEnvironmentVariables method to merge with defaults + $vars = json_decode(VARS_JSON, true); + $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars); + + writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => $input['httpPort'] ?? DEFAULT_HTTP_PORT, + 'httpsPort' => $input['httpsPort'] ?? DEFAULT_HTTPS_PORT, + 'database' => $input['database'] ?? 'mongodb', + 'appDomain' => $input['appDomain'] ?? 'localhost', + 'emailCertificates' => $input['emailCertificates'] ?? '', + 'opensslKey' => $input['opensslKey'] ?? '', + 'opensslKeyHash' => hashSensitiveValue((string) ($input['opensslKey'] ?? '')), + 'assistantOpenAIKey' => $input['assistantOpenAIKey'] ?? '', + 'assistantOpenAIKeyHash' => hashSensitiveValue((string) ($input['assistantOpenAIKey'] ?? '')), + ], + 'step' => 'start', + 'status' => 'in-progress', + 'message' => 'Installation started', + 'updatedAt' => time(), + ]); + + $progress = function (string $step, string $status, string $message, array $details = []) use ($installId, $wantsStream) { + $payload = [ + 'installId' => $installId, + 'step' => $step, + 'status' => $status, + 'message' => $message, + 'updatedAt' => time(), + ]; + if (!empty($details)) { + $payload['details'] = $details; + } + writeProgressFile($installId, $payload); + updateGlobalLock($installId, 'in-progress'); + if ($wantsStream) { + sendSseEvent('progress', $payload); + } + }; + + // Call performInstallation method + $installer->performInstallation( + $input['httpPort'] ?? DEFAULT_HTTP_PORT, + $input['httpsPort'] ?? DEFAULT_HTTPS_PORT, + ORGANIZATION, + IMAGE, + $envVars, + NO_START, + $progress, + $retryStep, + IS_UPGRADE + ); + + if ($wantsStream) { + sendSseEvent('done', ['installId' => $installId, 'success' => true]); + } else { + echo json_encode([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully' + ]); + } + updateGlobalLock($installId, 'completed'); + + } catch (\Throwable $e) { + http_response_code(500); + if ($installId !== '') { + $details = ['trace' => $e->getTraceAsString()]; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + writeProgressFile($installId, [ + 'step' => 'error', + 'status' => 'error', + 'message' => $e->getMessage(), + 'details' => $details, + 'updatedAt' => time(), + ]); + } + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + if ($wantsStream) { + $details = ['trace' => $e->getTraceAsString()]; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + sendSseEvent('error', ['message' => $e->getMessage(), 'details' => $details]); + } else { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + } + exit; +} + +// Serve installer UI +if ($uri === '/' || $uri === '') { + sendInstallerHtmlHeaders(); + + $vars = json_decode(VARS_JSON, true); + $defaultHttpPort = DEFAULT_HTTP_PORT; + $defaultHttpsPort = DEFAULT_HTTPS_PORT; + $isUpgrade = IS_UPGRADE; + $lockedDatabase = LOCKED_DATABASE; + + include APP_VIEWS_PATH . '/' . INSTALLER_VIEW; + exit; +} + +// 404 +http_response_code(404); +echo '404 Not Found'; +PHP; + + $varsJson = json_encode($vars, JSON_UNESCAPED_SLASHES); + return sprintf( + $script, + $vendorPath, + $appwritePath, + $installPhpPath, + $appViewsPath, + $publicPath, + $defaultHTTPPort, + $defaultHTTPSPort, + $organization, + $image, + var_export($noStart, true), + str_replace("'", "\\'", $varsJson), + var_export($isUpgrade, true), + var_export($lockedDatabase, true) + ); + } +} diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 1ede049b19..9e09c1f25a 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Docker\Compose; use Appwrite\Docker\Env; +use Appwrite\Platform\Installer\Server as InstallerServer; use Appwrite\Utopia\View; use Utopia\Auth\Proofs\Password; use Utopia\Auth\Proofs\Token; @@ -15,9 +16,9 @@ use Utopia\Validator\Text; class Install extends Action { - private const INSTALL_STEP_DELAY_SECONDS = 2; + private const int INSTALL_STEP_DELAY_SECONDS = 2; + protected string $path = '/usr/src/code/appwrite'; - protected ?string $hostPath = null; public static function getName(): string { @@ -36,8 +37,6 @@ class Install extends Action ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) ->callback($this->action(...)); - $this->hostPath = $this->detectHostPath($this->path); - $this->ensureHostPathLink(); } public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void @@ -145,7 +144,7 @@ class Install extends Action // If interactive and web mode enabled, start web server if ($interactive === 'Y' && Console::isInteractive()) { Console::success('Starting web installer...'); - Console::info('Open your browser at: http://localhost:8080'); + Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT); Console::info('Press Ctrl+C to cancel installation'); $this->startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars); @@ -153,6 +152,30 @@ class Install extends Action } // Fall back to CLI mode + $enableAssistant = false; + $assistantExistsInOldCompose = false; + if ($existingInstallation && isset($compose)) { + try { + $assistantService = $compose->getService('appwrite-assistant'); + $assistantExistsInOldCompose = $assistantService !== null; + } catch (\Throwable) { + /* ignore */ + } + } + + if ($interactive === 'Y' && Console::isInteractive()) { + $prompt = 'Add Appwrite Assistant? (Y/n)' . ($assistantExistsInOldCompose ? ' [Currently enabled]' : ''); + $answer = Console::confirm($prompt); + + if (empty($answer)) { + $enableAssistant = $assistantExistsInOldCompose; + } else { + $enableAssistant = \strtolower($answer) === 'y'; + } + } elseif ($assistantExistsInOldCompose) { + $enableAssistant = true; + } + if (empty($httpPort)) { $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; @@ -163,9 +186,33 @@ class Install extends Action $httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort; } - $password = new Password(); - $token = new Token(); + $userInput = []; foreach ($vars as $var) { + if ($var['name'] === '_APP_ASSISTANT_OPENAI_API_KEY') { + if (!$enableAssistant) { + $userInput[$var['name']] = ''; + continue; + } + + if (!empty($var['default'])) { + $userInput[$var['name']] = $var['default']; + continue; + } + + if (Console::isInteractive() && $interactive === 'Y') { + $userInput[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:'); + if (empty($userInput[$var['name']])) { + Console::warning('No API key provided. Assistant will be disabled.'); + $enableAssistant = false; + $userInput[$var['name']] = ''; + } + } else { + $userInput[$var['name']] = ''; + } + + continue; + } + if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') { continue; } @@ -189,112 +236,13 @@ class Install extends Action $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); } - private function detectHostPath(string $path): ?string - { - if (!is_file('/proc/self/mountinfo')) { - return null; - } - - $bestMatch = null; - $bestLength = 0; - - foreach (@file('/proc/self/mountinfo') as $line) { - $line = trim($line); - if ($line === '') { - continue; - } - - $parts = explode(' - ', $line, 2); - if (count($parts) < 2) { - continue; - } - - $left = preg_split('/\s+/', $parts[0]); - $right = preg_split('/\s+/', $parts[1]); - - if (count($left) < 5 || count($right) < 2) { - continue; - } - - $mountPoint = $left[4]; - $root = $left[3]; - $fsType = $right[0]; - $source = $right[1]; - - if (!str_starts_with($path, $mountPoint)) { - continue; - } - - $mountLength = strlen($mountPoint); - if ($mountLength < $bestLength) { - continue; - } - - $relative = substr($path, $mountLength); - $relative = ltrim($relative, '/'); - - $hostBase = null; - - if ($fsType === 'fakeowner' && str_starts_with($source, '/run/host_mark/')) { - $share = basename($source); - $hostBase = '/' . $share . rtrim($root, '/'); - } elseif ($root !== '/' && $root !== '.') { - $hostBase = $root; - } elseif ($source !== 'none') { - $hostBase = $source; - } - - if (!$hostBase || $hostBase === '.') { - continue; - } - - if ($hostBase[0] !== '/') { - $hostBase = '/' . $hostBase; - } - - $candidate = rtrim($hostBase, '/'); - if ($relative !== '') { - $candidate .= '/' . $relative; - } - - $candidate = preg_replace('#//+#', '/', $candidate); - - $bestMatch = $candidate; - $bestLength = $mountLength; - } - - return $bestMatch; - } - - private function ensureHostPathLink(): void - { - if (empty($this->hostPath) || $this->hostPath === $this->path) { - return; - } - - if (is_link($this->hostPath)) { - return; - } - - if (is_dir($this->hostPath)) { - @rmdir($this->hostPath); - } - - $parent = dirname($this->hostPath); - if (!is_dir($parent)) { - @mkdir($parent, 0755, true); - } - - if (!file_exists($this->hostPath)) { - @symlink($this->path, $this->hostPath); - } - } protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void { - $port = 8080; + $port = getenv('APPWRITE_INSTALLER_LOCAL') + ? InstallerServer::INSTALLER_WEB_PORT_INTERNAL + : InstallerServer::INSTALLER_WEB_PORT; $host = '0.0.0.0'; - $url = "http://localhost:$port"; // Create a router script for handling requests $routerScript = \sys_get_temp_dir() . '/appwrite-installer-router.php'; @@ -355,712 +303,22 @@ class Install extends Action $vendorPath = __DIR__ . '/../../../../vendor/autoload.php'; $appwritePath = __DIR__ . '/../../../../app/init.php'; - $script = <<<'PHP' - INSTALL_LOCK_TTL) { - return false; - } - - return true; -} - -function withGlobalLock(callable $callback) -{ - $path = globalLockPath(); - $handle = fopen($path, 'c+'); - if ($handle === false) { - return $callback(null, null); - } - if (!flock($handle, LOCK_EX)) { - fclose($handle); - return $callback(null, null); - } - - $contents = stream_get_contents($handle); - $lock = null; - if ($contents !== false && $contents !== '') { - $decoded = json_decode($contents, true); - if (is_array($decoded)) { - $lock = $decoded; - } - } - - $result = $callback($handle, $lock); - - fflush($handle); - flock($handle, LOCK_UN); - fclose($handle); - - return $result; -} - -function reserveGlobalLock(string $installId): string -{ - return (string) withGlobalLock(function ($handle, $lock) use ($installId) { - if (!$handle) { - return 'unavailable'; - } - if (isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { - return 'locked'; - } - $payload = [ - 'installId' => $installId, - 'status' => 'in-progress', - 'updatedAt' => time(), - ]; - ftruncate($handle, 0); - rewind($handle); - fwrite($handle, json_encode($payload)); - return 'ok'; - }); -} - -function updateGlobalLock(string $installId, string $status): void -{ - withGlobalLock(function ($handle, $lock) use ($installId, $status) { - if (!$handle) { - return; - } - if (isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { - return; - } - $payload = [ - 'installId' => $installId, - 'status' => $status, - 'updatedAt' => time(), - ]; - ftruncate($handle, 0); - rewind($handle); - fwrite($handle, json_encode($payload)); - }); -} - -function readProgressFile(string $installId): array -{ - $path = progressFilePath($installId); - if (!file_exists($path)) { - return [ - 'installId' => $installId, - 'steps' => [], - ]; - } - - $contents = file_get_contents($path); - if ($contents === false) { - return [ - 'installId' => $installId, - 'steps' => [], - ]; - } - - $data = json_decode($contents, true); - if (!is_array($data)) { - return [ - 'installId' => $installId, - 'steps' => [], - ]; - } - - return $data; -} - -function writeProgressFile(string $installId, array $payload): void -{ - $data = readProgressFile($installId); - if (!isset($data['steps']) || !is_array($data['steps'])) { - $data['steps'] = []; - } - - if (!empty($payload['step'])) { - $data['steps'][$payload['step']] = [ - 'status' => $payload['status'] ?? 'in-progress', - 'message' => $payload['message'] ?? '', - 'updatedAt' => $payload['updatedAt'] ?? time(), - ]; - } - - if (!empty($payload['status']) && $payload['status'] === 'error') { - $data['error'] = $payload['message'] ?? 'Installation failed'; - } - - if (isset($payload['details']) && is_array($payload['details'])) { - $data['details'][$payload['step']] = $payload['details']; - } - - if (isset($payload['payload']) && is_array($payload['payload'])) { - $data['payload'] = $payload['payload']; - if (!isset($data['startedAt'])) { - $data['startedAt'] = $payload['updatedAt'] ?? time(); - } - } - - $data['updatedAt'] = $payload['updatedAt'] ?? time(); - - file_put_contents(progressFilePath($installId), json_encode($data)); -} - -function sendSseEvent(string $event, array $payload): void -{ - echo "event: {$event}\n"; - echo 'data: ' . json_encode($payload) . "\n\n"; - @ob_flush(); - @flush(); -} - -function respondBadRequest(string $message, bool $wantsStream): void -{ - if ($wantsStream) { - sendSseEvent('error', ['message' => $message, 'step' => 'config-files']); - } else { - http_response_code(400); - echo json_encode(['success' => false, 'message' => $message]); - } - exit; -} - -function hashSensitiveValue(string $value): string -{ - $trimmed = trim($value); - if ($trimmed === '') { - return ''; - } - return hash('sha256', $trimmed); -} - -function isValidPort($value): bool -{ - $string = (string) $value; - if ($string === '' || !preg_match('/^\d+$/', $string)) { - return false; - } - $port = (int) $string; - return $port >= 1 && $port <= 65535; -} - -function isValidEmailAddress(string $value): bool -{ - return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; -} - -function isValidAppDomain(string $value): bool -{ - if ($value === 'localhost') { - return true; - } - if (filter_var($value, FILTER_VALIDATE_IP) !== false) { - return true; - } - return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; -} - -function isValidAppDomainInput(string $value): bool -{ - $value = trim($value); - if ($value === '') { - return false; - } - - $host = $value; - $port = null; - - if (str_starts_with($value, '[')) { - if (!preg_match('/^\[(.+)\](?::(\d+))?$/', $value, $matches)) { - return false; - } - $host = $matches[1] ?? ''; - $port = $matches[2] ?? null; - } else { - $parts = explode(':', $value); - if (count($parts) > 2) { - return false; - } - if (count($parts) === 2) { - [$host, $port] = $parts; - } - } - - if ($port !== null && $port !== '' && !isValidPort($port)) { - return false; - } - - return isValidAppDomain($host); -} - -function isValidDatabaseAdapter(string $value): bool -{ - return in_array($value, ['mongodb', 'mariadb'], true); -} - -function sendInstallerHtmlHeaders(): void -{ - header('Content-Type: text/html; charset=utf-8'); - header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"); -} - -// Serve static files -if ($uri !== '/' && $uri !== '') { - $requestPath = $uri; - $publicBase = realpath(PUBLIC_PATH); - $viewsBase = realpath(APP_VIEWS_PATH); - $filePath = null; - - if ($publicBase !== false) { - $candidate = realpath($publicBase . $requestPath); - if ($candidate !== false && str_starts_with($candidate, $publicBase . DIRECTORY_SEPARATOR)) { - $filePath = $candidate; - } - } - - if (($filePath === null || !is_file($filePath)) && str_starts_with($uri, '/installer/') && $viewsBase !== false) { - $candidate = realpath($viewsBase . $requestPath); - if ($candidate !== false && str_starts_with($candidate, $viewsBase . DIRECTORY_SEPARATOR)) { - $filePath = $candidate; - } - } - - if ($filePath && is_file($filePath)) { - // Determine content type - $extension = pathinfo($filePath, PATHINFO_EXTENSION); - $mimeTypes = [ - 'css' => 'text/css', - 'js' => 'application/javascript', - 'json' => 'application/json', - 'png' => 'image/png', - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'gif' => 'image/gif', - 'svg' => 'image/svg+xml', - 'ico' => 'image/x-icon', - 'woff' => 'font/woff', - 'woff2' => 'font/woff2', - 'ttf' => 'font/ttf', - 'eot' => 'application/vnd.ms-fontobject', - ]; - - $contentType = $mimeTypes[$extension] ?? 'application/octet-stream'; - header('Content-Type: ' . $contentType); - header('Content-Length: ' . filesize($filePath)); - readfile($filePath); - exit; - } -} - -if ($_SERVER['REQUEST_METHOD'] === 'GET' && $uri === '/install/status') { - header('Content-Type: application/json'); - - $installId = sanitizeInstallId($_GET['installId'] ?? ''); - if ($installId === '') { - http_response_code(400); - echo json_encode(['success' => false, 'message' => 'Missing installId']); - exit; - } - - $path = progressFilePath($installId); - if (!file_exists($path)) { - http_response_code(404); - echo json_encode(['success' => false, 'message' => 'Install not found']); - exit; - } - - $data = readProgressFile($installId); - echo json_encode(['success' => true, 'progress' => $data]); - exit; -} - -// Handle POST request (completion) -if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install/complete') { - header('Content-Type: application/json'); - - $input = json_decode(file_get_contents('php://input'), true); - $installId = sanitizeInstallId($input['installId'] ?? ''); - - if ($installId !== '') { - updateGlobalLock($installId, 'completed'); - } - - echo json_encode(['success' => true]); - - if (function_exists('fastcgi_finish_request')) { - fastcgi_finish_request(); - } - - if (function_exists('posix_getpid') && PHP_OS_FAMILY !== 'Windows') { - $pid = posix_getpid(); - if ($pid) { - $delay = 3; - $command = 'sh -c ' . escapeshellarg("sleep {$delay}; kill {$pid} >/dev/null 2>&1"); - @exec($command . ' >/dev/null 2>&1 &'); - } - } - - exit; -} - -// Handle POST request (installation) -if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { - $acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? ''; - $acceptsStream = stripos($acceptHeader, 'text/event-stream') !== false; - $wantsStream = $acceptsStream; - - if ($wantsStream) { - header('Content-Type: text/event-stream'); - header('Cache-Control: no-cache'); - header('Connection: keep-alive'); - header('X-Accel-Buffering: no'); - @ini_set('output_buffering', 'off'); - @ini_set('zlib.output_compression', '0'); - @ini_set('implicit_flush', '1'); - if (function_exists('ob_get_level')) { - while (ob_get_level() > 0) { - @ob_end_flush(); - } - } - @ob_implicit_flush(true); - } else { - header('Content-Type: application/json'); - } - - $input = json_decode(file_get_contents('php://input'), true); - - if (!$input) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => 'Invalid request']); - exit; - } - - $appDomain = trim((string) ($input['appDomain'] ?? '')); - if ($appDomain === '' || !isValidAppDomainInput($appDomain)) { - respondBadRequest('Please enter a valid hostname', $wantsStream); - } - $input['appDomain'] = $appDomain; - - $httpPort = $input['httpPort'] ?? ''; - if (!isValidPort($httpPort)) { - respondBadRequest('Please enter a valid HTTP port (1-65535)', $wantsStream); - } - - $httpsPort = $input['httpsPort'] ?? ''; - if (!isValidPort($httpsPort)) { - respondBadRequest('Please enter a valid HTTPS port (1-65535)', $wantsStream); - } - - $emailCertificates = trim((string) ($input['emailCertificates'] ?? '')); - if ($emailCertificates === '' || !isValidEmailAddress($emailCertificates)) { - respondBadRequest('Please enter a valid email address', $wantsStream); - } - $input['emailCertificates'] = $emailCertificates; - - $opensslKey = trim((string) ($input['opensslKey'] ?? '')); - if ($opensslKey === '' || strlen($opensslKey) > 64) { - respondBadRequest('Secret API key must be 1-64 characters', $wantsStream); - } - $input['opensslKey'] = $opensslKey; - - $assistantOpenAIKey = trim((string) ($input['assistantOpenAIKey'] ?? '')); - $input['assistantOpenAIKey'] = $assistantOpenAIKey; - - if (!LOCKED_DATABASE) { - $database = strtolower(trim((string) ($input['database'] ?? ''))); - if (!isValidDatabaseAdapter($database)) { - respondBadRequest('Please select a supported database', $wantsStream); - } - $input['database'] = $database; - } - - $installId = sanitizeInstallId($input['installId'] ?? ''); - if ($installId === '') { - $installId = bin2hex(random_bytes(8)); - } - - $lockResult = reserveGlobalLock($installId); - if ($lockResult !== 'ok') { - if ($lockResult === 'locked') { - http_response_code(409); - echo json_encode(['success' => false, 'message' => 'Installation already in progress']); - } else { - http_response_code(503); - echo json_encode(['success' => false, 'message' => 'Installer lock unavailable']); - } - exit; - } - - $retryStep = $input['retryStep'] ?? null; - $allowedRetrySteps = ['docker-compose', 'env-vars', 'docker-containers']; - if (!is_string($retryStep) || !in_array($retryStep, $allowedRetrySteps, true)) { - $retryStep = null; - } - - $existingPath = progressFilePath($installId); - $existing = null; - if (file_exists($existingPath)) { - $existing = readProgressFile($installId); - if (!empty($existing['steps']) && $retryStep === null) { - http_response_code(409); - echo json_encode(['success' => false, 'message' => 'Installation already started']); - exit; - } - } - - try { - ignore_user_abort(true); - require_once INSTALL_PHP_PATH; - $installer = new \Appwrite\Platform\Tasks\Install(); - - if ($wantsStream) { - sendSseEvent('install-id', ['installId' => $installId]); - } - - updateGlobalLock($installId, 'in-progress'); - - // Prepare user inputs - use locked database if in upgrade mode - $payloadInput = [ - '_APP_ENV' => 'production', - '_APP_OPENSSL_KEY_V1' => $input['opensslKey'] ?? '', - '_APP_DOMAIN' => $input['appDomain'] ?? 'localhost', - '_APP_DOMAIN_TARGET' => $input['appDomain'] ?? 'localhost', - '_APP_EMAIL_CERTIFICATES' => $input['emailCertificates'] ?? '', - '_APP_DB_ADAPTER' => LOCKED_DATABASE ?? ($input['database'] ?? 'mongodb'), - '_APP_ASSISTANT_OPENAI_API_KEY' => $input['assistantOpenAIKey'] ?? '', - ]; - - if (is_array($existing) && isset($existing['payload']) && is_array($existing['payload'])) { - $stored = $existing['payload']; - $fieldsToCompare = [ - 'httpPort', - 'httpsPort', - 'database', - 'appDomain', - 'emailCertificates', - ]; - foreach ($fieldsToCompare as $field) { - if (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { - if ($installId !== '') { - updateGlobalLock($installId, 'error'); - } - respondBadRequest('Installation payload mismatch', $wantsStream); - } - } - - $sensitiveFields = [ - 'opensslKey' => 'opensslKeyHash', - 'assistantOpenAIKey' => 'assistantOpenAIKeyHash', - ]; - foreach ($sensitiveFields as $field => $hashField) { - if (!isset($stored[$hashField]) && !isset($stored[$field])) { - continue; - } - $incomingHash = hashSensitiveValue((string) ($input[$field] ?? '')); - if (isset($stored[$hashField])) { - if ((string) $stored[$hashField] !== $incomingHash) { - if ($installId !== '') { - updateGlobalLock($installId, 'error'); - } - respondBadRequest('Installation payload mismatch', $wantsStream); - } - } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { - if ($installId !== '') { - updateGlobalLock($installId, 'error'); - } - respondBadRequest('Installation payload mismatch', $wantsStream); - } - } - - $payloadInput['_APP_DOMAIN'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN']; - $payloadInput['_APP_DOMAIN_TARGET'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN_TARGET']; - $payloadInput['_APP_EMAIL_CERTIFICATES'] = $stored['emailCertificates'] ?? $payloadInput['_APP_EMAIL_CERTIFICATES']; - $payloadInput['_APP_DB_ADAPTER'] = LOCKED_DATABASE ?? ($stored['database'] ?? $payloadInput['_APP_DB_ADAPTER']); - $input['httpPort'] = $stored['httpPort'] ?? $input['httpPort'] ?? DEFAULT_HTTP_PORT; - $input['httpsPort'] = $stored['httpsPort'] ?? $input['httpsPort'] ?? DEFAULT_HTTPS_PORT; - } - - // Use the prepareEnvironmentVariables method to merge with defaults - $vars = json_decode(VARS_JSON, true); - $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars); - - writeProgressFile($installId, [ - 'payload' => [ - 'httpPort' => $input['httpPort'] ?? DEFAULT_HTTP_PORT, - 'httpsPort' => $input['httpsPort'] ?? DEFAULT_HTTPS_PORT, - 'database' => $input['database'] ?? 'mongodb', - 'appDomain' => $input['appDomain'] ?? 'localhost', - 'emailCertificates' => $input['emailCertificates'] ?? '', - 'opensslKey' => $input['opensslKey'] ?? '', - 'opensslKeyHash' => hashSensitiveValue((string) ($input['opensslKey'] ?? '')), - 'assistantOpenAIKey' => $input['assistantOpenAIKey'] ?? '', - 'assistantOpenAIKeyHash' => hashSensitiveValue((string) ($input['assistantOpenAIKey'] ?? '')), - ], - 'step' => 'start', - 'status' => 'in-progress', - 'message' => 'Installation started', - 'updatedAt' => time(), - ]); - - $progress = function (string $step, string $status, string $message, array $details = []) use ($installId, $wantsStream) { - $payload = [ - 'installId' => $installId, - 'step' => $step, - 'status' => $status, - 'message' => $message, - 'updatedAt' => time(), - ]; - if (!empty($details)) { - $payload['details'] = $details; - } - writeProgressFile($installId, $payload); - updateGlobalLock($installId, 'in-progress'); - if ($wantsStream) { - sendSseEvent('progress', $payload); - } - }; - - // Call performInstallation method - $installer->performInstallation( - $input['httpPort'] ?? DEFAULT_HTTP_PORT, - $input['httpsPort'] ?? DEFAULT_HTTPS_PORT, - ORGANIZATION, - IMAGE, - $envVars, - NO_START, - $progress, - $retryStep, - IS_UPGRADE - ); - - if ($wantsStream) { - sendSseEvent('done', ['installId' => $installId, 'success' => true]); - } else { - echo json_encode([ - 'success' => true, - 'installId' => $installId, - 'message' => 'Installation completed successfully' - ]); - } - updateGlobalLock($installId, 'completed'); - - } catch (\Throwable $e) { - http_response_code(500); - if ($installId !== '') { - $details = ['trace' => $e->getTraceAsString()]; - $previous = $e->getPrevious(); - if ($previous instanceof \Throwable && $previous->getMessage() !== '') { - $details['output'] = $previous->getMessage(); - } - writeProgressFile($installId, [ - 'step' => 'error', - 'status' => 'error', - 'message' => $e->getMessage(), - 'details' => $details, - 'updatedAt' => time(), - ]); - } - if ($installId !== '') { - updateGlobalLock($installId, 'error'); - } - if ($wantsStream) { - $details = ['trace' => $e->getTraceAsString()]; - $previous = $e->getPrevious(); - if ($previous instanceof \Throwable && $previous->getMessage() !== '') { - $details['output'] = $previous->getMessage(); - } - sendSseEvent('error', ['message' => $e->getMessage(), 'details' => $details]); - } else { - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - } - exit; -} - -// Serve installer UI -if ($uri === '/' || $uri === '') { - sendInstallerHtmlHeaders(); - - $vars = json_decode(VARS_JSON, true); - $defaultHttpPort = DEFAULT_HTTP_PORT; - $defaultHttpsPort = DEFAULT_HTTPS_PORT; - $isUpgrade = IS_UPGRADE; - $lockedDatabase = LOCKED_DATABASE; - - include APP_VIEWS_PATH . '/' . INSTALLER_VIEW; - exit; -} - -// 404 -http_response_code(404); -echo '404 Not Found'; -PHP; - - $varsJson = json_encode($vars, JSON_UNESCAPED_SLASHES); - $script = sprintf( - $script, - $vendorPath, - $appwritePath, + InstallerServer::writeRouterScript( + $path, $installPhpPath, $appViewsPath, $publicPath, + $vendorPath, + $appwritePath, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, - var_export($noStart, true), - str_replace("'", "\\'", $varsJson), - var_export($isUpgrade, true), - var_export($lockedDatabase, true) + $noStart, + $vars, + $isUpgrade, + $lockedDatabase ); - - file_put_contents($path, $script); } public function prepareEnvironmentVariables(array $userInput, array $vars): array @@ -1082,7 +340,7 @@ PHP; // Override with user inputs foreach ($userInput as $key => $value) { - if ($value !== null && $value !== '') { + if ($value !== null && ($value !== '' || $key === '_APP_ASSISTANT_OPENAI_API_KEY')) { $input[$key] = $value; } } @@ -1156,8 +414,7 @@ PHP; ->setParam('version', $version) ->setParam('organization', $organization) ->setParam('image', $image) - ->setParam('database', $database) - ->setParam('hostPath', $this->hostPath); + ->setParam('database', $database); $templateForEnv->setParam('vars', $input);