From cbf0cc842df53dbfdf6907fb87ab9bb7363aee86 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 2 Dec 2025 23:33:21 +1300 Subject: [PATCH 01/46] Add web installer --- .gitignore | 4 +- Dockerfile | 2 + app/views/install/compose.phtml | 61 +- app/views/install/installer.phtml | 832 ++++++++++++++++++ composer.lock | 12 +- mongo-entrypoint.sh | 37 + package-lock.json | 79 ++ package.json | 27 +- playwright.config.js | 36 + public/images/logos/mariadb.svg | 1 + public/images/logos/mongodb.svg | 1 + .../Databases/Collections/Documents/Get.php | 4 +- .../Databases/Collections/Documents/XList.php | 3 + src/Appwrite/Platform/Tasks/Install.php | 496 +++++++++-- src/Appwrite/Platform/Tasks/Upgrade.php | 15 +- .../playwright/installer-screenshots.spec.js | 52 ++ tests/playwright/installer.spec.js | 132 +++ 17 files changed, 1693 insertions(+), 101 deletions(-) create mode 100644 app/views/install/installer.phtml create mode 100644 mongo-entrypoint.sh create mode 100644 package-lock.json create mode 100644 playwright.config.js create mode 100644 public/images/logos/mariadb.svg create mode 100644 public/images/logos/mongodb.svg create mode 100644 tests/playwright/installer-screenshots.spec.js create mode 100644 tests/playwright/installer.spec.js diff --git a/.gitignore b/.gitignore index 1d0f0533f2..4188afacf9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ dev/yasd_init.php .phpunit.result.cache Makefile appwrite.config.json -/.zed/ \ No newline at end of file +/.zed/ +playwright-report +test-results diff --git a/Dockerfile b/Dockerfile index d097edf0ca..d13d23309c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,7 @@ COPY ./bin /usr/local/bin COPY ./docs /usr/src/code/docs COPY ./src /usr/src/code/src COPY ./dev /usr/src/code/dev +COPY ./mongo-entrypoint.sh /usr/src/code/mongo-entrypoint.sh # Set Volumes RUN mkdir -p /storage/uploads && \ @@ -101,5 +102,6 @@ RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xdebug.so; fi EXPOSE 80 +EXPOSE 8080 CMD [ "php", "app/http.php" ] diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index e3ca65fa97..a934769713 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -11,6 +11,9 @@ $httpsPort = $this->getParam('httpsPort', ''); $version = $this->getParam('version', ''); $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); +$dbService = $this->getParam('database'); +$hostPath = $this->getParam('hostPath', ''); + ?>services: traefik: image: traefik:2.11 @@ -69,7 +72,7 @@ $image = $this->getParam('image', ''); - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw depends_on: - - mariadb + - - redis # - clamav environment: @@ -221,7 +224,7 @@ $image = $this->getParam('image', ''); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -251,7 +254,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -277,7 +280,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -305,7 +308,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - volumes: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw @@ -368,7 +371,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -394,7 +397,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - volumes: - appwrite-functions:/storage/functions:rw - appwrite-sites:/storage/sites:rw @@ -462,7 +465,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - volumes: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw @@ -499,7 +502,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - - openruntimes-executor environment: - _APP_ENV @@ -624,7 +627,7 @@ $image = $this->getParam('image', ''); volumes: - appwrite-imports:/storage/imports:rw depends_on: - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -698,7 +701,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -727,7 +730,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -755,7 +758,7 @@ $image = $this->getParam('image', ''); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -782,7 +785,7 @@ $image = $this->getParam('image', ''); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -807,7 +810,7 @@ $image = $this->getParam('image', ''); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -832,7 +835,7 @@ $image = $this->getParam('image', ''); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -918,6 +921,7 @@ $image = $this->getParam('image', ''); - OPR_EXECUTOR_STORAGE_WASABI_REGION=$_APP_STORAGE_WASABI_REGION - OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET + mariadb: image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p container_name: appwrite-mariadb @@ -927,6 +931,8 @@ $image = $this->getParam('image', ''); - appwrite volumes: - appwrite-mariadb:/var/lib/mysql:rw + ports: + - "3306:3306" environment: - MYSQL_ROOT_PASSWORD=${_APP_DB_ROOT_PASS} - MYSQL_DATABASE=${_APP_DB_SCHEMA} @@ -934,6 +940,24 @@ $image = $this->getParam('image', ''); - MYSQL_PASSWORD=${_APP_DB_PASS} - MARIADB_AUTO_UPGRADE=1 command: 'mysqld --innodb-flush-method=fsync' + + mongodb: + image: mongo:8.0.8 + container_name: appwrite-mongodb + <<: *x-logging + restart: unless-stopped + networks: + - appwrite + volumes: + - appwrite-mongodb:/data/db:rw + - appwrite-mongodb-config:/data/configdb:rw + - ./mongo-entrypoint.sh:/mongo-entrypoint.sh:ro + ports: + - "27017:27017" + environment: + - MONGO_INITDB_DATABASE=${_APP_DB_SCHEMA} + entrypoint: ["/bin/bash", "/mongo-entrypoint.sh"] + redis: image: redis:7.2.4-alpine @@ -968,7 +992,12 @@ networks: name: runtimes volumes: + appwrite-mariadb: + + appwrite-mongodb: + appwrite-mongodb-config: + appwrite-redis: appwrite-cache: appwrite-uploads: diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml new file mode 100644 index 0000000000..793b5b9b96 --- /dev/null +++ b/app/views/install/installer.phtml @@ -0,0 +1,832 @@ + + + + + + + <?php echo $isUpgrade ? 'Appwrite Upgrade' : 'Appwrite Installation'; ?> + + + + + +
+ +
+ +
+
+
+ +

+

+
+ +
+
+
+ +

Step 1 of 6

+ +
+ + +
+
+
+

Configuration

+
+ +
+
    +
  • + +
    + +
    +

    The port for HTTP connections (default: 80)

    +
  • +
  • + +
    + +
    +

    The port for HTTPS connections (default: 443)

    +
  • +
+
+
+ +
+ +
+
+ + + +
+
+
+

Database

+

Choose your preferred database engine

+
+ +
+ + + +
+
+ +
+ + +
+
+ + + +
+
+
+

Domain

+
+ +
+
    +
  • + +
    + +
    +

    Your Appwrite domain address (e.g., appwrite.example.com)

    +
  • +
  • + +
    + +
    +

    Email address for SSL certificate registration

    +
  • +
+
+
+ +
+ + +
+
+ + +
+
+
+

Security

+
+ +
+
    +
  • + +
    + + +
    +

    Your server private secret key for encryption. Keep this safe!

    +
  • +
+
+ +
+

Note: Use the Generate button for a secure random key, or provide your own. Make sure to back it up in a secure location.

+
+
+ +
+ + +
+
+ + +
+
+
+

Review

+

Please review your settings before installation

+
+ +
+
+ +
+ + +
+ + +
+ + +
+
+

+

Your Appwrite instance has been successfully and is now running.

+ +
+

Next Steps:

+
    +
  1. Access your Appwrite console at: http:///console
  2. +
  3. Create your first project and start building!
  4. +
+
+ +
+ +
+
+
+
+
+ + + + diff --git a/composer.lock b/composer.lock index 4dccb29a1a..417c9d3c03 100644 --- a/composer.lock +++ b/composer.lock @@ -6663,16 +6663,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.29", + "version": "9.6.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3" + "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", - "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b69489b312503bf8fa6d75a76916919d7d2fa6d4", + "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4", "shasum": "" }, "require": { @@ -6746,7 +6746,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.30" }, "funding": [ { @@ -6770,7 +6770,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:29:11+00:00" + "time": "2025-12-01T07:35:08+00:00" }, { "name": "psr/cache", diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh new file mode 100644 index 0000000000..36b8b930dd --- /dev/null +++ b/mongo-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +# Start MongoDB in the background +mongod --replSet rs0 --bind_ip_all & + +# Wait for MongoDB to start +echo "Waiting for MongoDB to start..." +until mongosh --eval "print('MongoDB is ready')" > /dev/null 2>&1; do + sleep 1 +done + +# Initialize replica set if not already initialized +echo "Initializing replica set..." +mongosh --eval " +try { + rs.status(); + print('Replica set already initialized'); +} catch (e) { + rs.initiate({ + _id: 'rs0', + members: [{ _id: 0, host: 'localhost:27017' }] + }); + print('Replica set initialized'); +} +" + +# Wait for replica set to be ready +echo "Waiting for replica set to be ready..." +until mongosh --eval "rs.status().ok" > /dev/null 2>&1; do + sleep 1 +done + +echo "MongoDB replica set is ready" + +# Keep the container running by waiting on the MongoDB process +wait diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..fdb9a4975d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "@appwrite.io/repo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@appwrite.io/repo", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.56.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json index 1e2b1be430..84dd910930 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,30 @@ "files": [ "docs/examples", "app/config/specs" - ] + ], + "version": "1.0.0", + "description": "> We just announced Timestamp Overrides for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-timestamp-overrides)", + "main": "index.js", + "directories": { + "doc": "docs", + "test": "tests" + }, + "scripts": { + "test": "playwright test", + "test:installer": "playwright test tests/playwright/installer.spec.js", + "test:screenshots": "playwright test tests/playwright/installer-screenshots.spec.js --project=chromium", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/appwrite/appwrite/issues" + }, + "homepage": "https://github.com/appwrite/appwrite#readme", + "devDependencies": { + "@playwright/test": "^1.56.0" + } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000000..7193d8b28f --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,36 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './tests/playwright', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + screenshot: 'on', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}); diff --git a/public/images/logos/mariadb.svg b/public/images/logos/mariadb.svg new file mode 100644 index 0000000000..0d00509e3d --- /dev/null +++ b/public/images/logos/mariadb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/logos/mongodb.svg b/public/images/logos/mongodb.svg new file mode 100644 index 0000000000..54403d5284 --- /dev/null +++ b/public/images/logos/mongodb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 5f2a966220..1abd61310d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -102,11 +102,9 @@ class Get extends Action // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries); - } elseif (! empty($selects)) { - // has selects, allow relationship on documents! + } elseif (!empty($selects)) { $document = $dbForProject->getDocument($collectionTableId, $documentId, $queries); } else { - // has no selects, disable relationship looping on documents! $document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries)); } } catch (QueryException $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 8bbaecedc9..0edeb40596 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -16,6 +16,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; @@ -150,6 +151,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } catch (Timeout) { + throw new Exception(Exception::DATABASE_TIMEOUT); } $operations = 0; diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index b210a020b9..9c51c4a91c 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -12,10 +12,12 @@ use Utopia\Config\Config; use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Install extends Action { protected string $path = '/usr/src/code/appwrite'; + protected ?string $hostPath = null; public static function getName(): string { @@ -33,6 +35,9 @@ class Install extends Action ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->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): void @@ -40,7 +45,7 @@ class Install extends Action $config = Config::getParam('variables'); $defaultHTTPPort = '80'; $defaultHTTPSPort = '443'; - /** @var array> $vars array whre key is variable name and value is variable */ + /** @var array> $vars array where key is variable name and value is variable */ $vars = []; foreach ($config as $category) { @@ -59,18 +64,11 @@ class Install extends Action } } + // Check for existing installation $data = @file_get_contents($this->path . '/docker-compose.yml'); + $existingInstallation = $data !== false; - if ($data !== false) { - if ($interactive == 'Y' && Console::isInteractive()) { - $answer = Console::confirm('Previous installation found, do you want to overwrite it (a backup will be created before overwriting)? (Y/n)'); - - if (\strtolower($answer) !== 'y') { - Console::info('No action taken.'); - return; - } - } - + if ($existingInstallation) { $time = \time(); Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup'); file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data); @@ -88,7 +86,7 @@ class Install extends Action } if ($oldVersion) { - foreach ($compose->getServices() as $service) { // Fetch all env vars from previous compose file + foreach ($compose->getServices() as $service) { if (!$service) { continue; } @@ -107,12 +105,12 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/.env'); - if ($data !== false) { // Fetch all env vars from previous .env file + if ($envData !== false) { Console::info('Env file found, creating backup: .env.' . $time . '.backup'); - file_put_contents($this->path . '/.env.' . $time . '.backup', $data); - $env = new Env($data); + file_put_contents($this->path . '/.env.' . $time . '.backup', $envData); + $env = new Env($envData); foreach ($env->list() as $key => $value) { if (is_null($value)) { @@ -138,6 +136,17 @@ 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('Press Ctrl+C to cancel installation'); + + $this->startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars); + return; + } + + // Fall back to CLI mode if (empty($httpPort)) { $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; @@ -148,97 +157,438 @@ class Install extends Action $httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort; } - $input = []; - $password = new Password(); $token = new Token(); foreach ($vars as $var) { - if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) { - if ($data && $var['default'] !== null) { - $input[$var['name']] = $var['default']; - continue; - } - - if ($var['filter'] === 'token') { - $input[$var['name']] = $token->generate(); - continue; - } - - if ($var['filter'] === 'password') { - $input[$var['name']] = $password->generate(); - continue; - } - } if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') { - $input[$var['name']] = $var['default']; continue; } - $input[$var['name']] = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')'); + $value = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')'); - if (empty($input[$var['name']])) { - $input[$var['name']] = $var['default']; + if (!empty($value)) { + $userInput[$var['name']] = $value; } - if ($var['filter'] === 'domainTarget') { - if ($input[$var['name']] !== 'localhost') { - Console::warning("\nIf you haven't already done so, set the following record for {$input[$var['name']]} on your DNS provider:\n"); - $mask = "%-15.15s %-10.10s %-30.30s\n"; - printf($mask, "Type", "Name", "Value"); - printf($mask, "A or AAAA", "@", ""); - Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n"); - } + if ($var['filter'] === 'domainTarget' && !empty($value) && $value !== 'localhost') { + Console::warning("\nIf you haven't already done so, set the following record for {$value} on your DNS provider:\n"); + $mask = "%-15.15s %-10.10s %-30.30s\n"; + printf($mask, "Type", "Name", "Value"); + printf($mask, "A or AAAA", "@", ""); + Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n"); } } + $input = $this->prepareEnvironmentVariables($userInput, $vars); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart); + } + + 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; + $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'; + $this->createRouterScript($routerScript, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, $isUpgrade, $lockedDatabase); + + // Start PHP built-in server in background + $command = \sprintf( + 'php -S %s:%d %s >/dev/null 2>&1 &', + $host, + $port, + \escapeshellarg($routerScript) + ); + + // Start the server + \exec($command, $output, $exitCode); + \sleep(3); + + // Check if the server actually started + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle === false) { + Console::exit(1); + } + \fclose($handle); + + // Wait for the server process to finish + while (true) { + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle === false) { + // Server has stopped + break; + } + \fclose($handle); + \sleep(1); + } + + // Cleanup + if (\file_exists($routerScript)) { + \unlink($routerScript); + } + } + + private function createRouterScript(string $path, string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void + { + $installPhpPath = __FILE__; + $appViewsPath = __DIR__ . '/../../../../app/views/install'; + $publicPath = __DIR__ . '/../../../../public'; + $vendorPath = __DIR__ . '/../../../../vendor/autoload.php'; + $appwritePath = __DIR__ . '/../../../../app/init.php'; + + $script = <<<'PHP' + '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; + } +} + +// Handle POST request (installation) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { + 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; + } + + try { + require_once INSTALL_PHP_PATH; + $installer = new \Appwrite\Platform\Tasks\Install(); + + // Prepare user inputs - use locked database if in upgrade mode + $userInput = [ + '_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'), + ]; + + // Use the prepareEnvironmentVariables method to merge with defaults + $vars = json_decode(VARS_JSON, true); + $envVars = $installer->prepareEnvironmentVariables($userInput, $vars); + + // Call performInstallation method + $installer->performInstallation( + $input['httpPort'] ?? DEFAULT_HTTP_PORT, + $input['httpsPort'] ?? DEFAULT_HTTPS_PORT, + ORGANIZATION, + IMAGE, + $envVars, + NO_START + ); + + echo json_encode(['success' => true, 'message' => 'Installation completed successfully']); + + // Stop the server after successful installation + register_shutdown_function(function() { + sleep(2); // Give time for response to be sent + posix_kill(posix_getpid(), SIGTERM); + }); + + } catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + exit; +} + +// Serve installer UI +if ($uri === '/' || $uri === '') { + header('Content-Type: text/html'); + + $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.phtml'; + exit; +} + +// 404 +http_response_code(404); +echo '404 Not Found'; +PHP; + + $varsJson = json_encode($vars, JSON_UNESCAPED_SLASHES); + $script = 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) + ); + + file_put_contents($path, $script); + } + + public function prepareEnvironmentVariables(array $userInput, array $vars): array + { + $input = []; + $password = new Password(); + $token = new Token(); + + // Start with all defaults + foreach ($vars as $var) { + if (!empty($var['filter']) && $var['filter'] === 'token') { + $input[$var['name']] = $token->generate(); + } elseif (!empty($var['filter']) && $var['filter'] === 'password') { + $input[$var['name']] = $password->generate(); + } else { + $input[$var['name']] = $var['default']; + } + } + + // Override with user inputs + foreach ($userInput as $key => $value) { + if ($value !== null && $value !== '') { + $input[$key] = $value; + } + } + + // Set database-specific connection details + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + if ($database === 'mongodb') { + $input['_APP_DB_HOST'] = 'mongodb'; + $input['_APP_DB_PORT'] = 27017; + } elseif ($database === 'mariadb') { + $input['_APP_DB_HOST'] = 'mariadb'; + $input['_APP_DB_PORT'] = 3306; + } + + return $input; + } + + public function performInstallation( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + array $input, + bool $noStart + ): void { + // Check if we're running in CLI context + $isCLI = php_sapi_name() === 'cli'; + $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml'); $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml'); + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + $templateForCompose ->setParam('httpPort', $httpPort) ->setParam('httpsPort', $httpsPort) - ->setParam('version', APP_VERSION_STABLE) + ->setParam('version', 'next') ->setParam('organization', $organization) - ->setParam('image', $image); + ->setParam('image', $image) + ->setParam('database', $database) + ->setParam('hostPath', $this->hostPath); $templateForEnv->setParam('vars', $input); - if (!file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { - $message = 'Failed to save Docker Compose file'; - Console::error($message); - Console::exit(1); + if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { + throw new \Exception('Failed to save Docker Compose file'); } - if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - $message = 'Failed to save environment variables file'; - Console::error($message); - Console::exit(1); + if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { + throw new \Exception('Failed to save environment variables file'); } - $env = ''; - $stdout = ''; - $stderr = ''; + // Copy MongoDB entrypoint script for replica set setup + if ($database === 'mongodb') { + $mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh'; - foreach ($input as $key => $value) { - if ($value) { - $env .= $key . '=' . \escapeshellarg($value) . ' '; + if (file_exists($mongoEntrypoint)) { + copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); } } - $exit = 0; if (!$noStart) { - Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); - $exit = Console::execute("$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes", '', $stdout, $stderr); - } + $env = ''; + foreach ($input as $key => $value) { + if ($value) { + $env .= $key . '=' . \escapeshellarg($value) . ' '; + } + } - if ($exit !== 0) { - $message = 'Failed to install Appwrite dockers'; - Console::error($message); - Console::error($stderr); - Console::exit($exit); + if ($isCLI) { + Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); + } + + $command = "$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes 2>&1"; + \exec($command, $output, $exit); + + if ($exit !== 0) { + throw new \Exception('Failed to start Appwrite: ' . implode("\n", $output)); + } + + if ($isCLI) { + Console::success('Appwrite installed successfully'); + } } else { - $message = 'Appwrite installed successfully'; - Console::success($message); + if ($isCLI) { + Console::success('Installation files created. Run "docker compose up -d" to start Appwrite'); + } } } } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index d6159d2718..09463a2605 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -8,6 +8,8 @@ use Utopia\Validator\Text; class Upgrade extends Install { + private ?string $lockedDatabase = null; + public static function getName(): string { return 'upgrade'; @@ -15,6 +17,8 @@ class Upgrade extends Install public function __construct() { + parent::__construct(); + $this ->desc('Upgrade Appwrite') ->param('http-port', '', new Text(4), 'Server HTTP port', true) @@ -39,6 +43,15 @@ class Upgrade extends Install Console::log(' └── docker-compose.yml'); Console::exit(1); } - parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart); + + $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb'); + $this->lockedDatabase = $database; + + parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, $database); + } + + protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void + { + parent::startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, true, $this->lockedDatabase); } } diff --git a/tests/playwright/installer-screenshots.spec.js b/tests/playwright/installer-screenshots.spec.js new file mode 100644 index 0000000000..3b909a5701 --- /dev/null +++ b/tests/playwright/installer-screenshots.spec.js @@ -0,0 +1,52 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +test.describe('Appwrite Web Installer - Screenshots', () => { + test('capture screenshots of wizard steps', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + await expect(page.locator('h1')).toContainText('Appwrite Installation'); + await page.screenshot({ path: 'tests/playwright/screenshots/step-1-configuration.png', fullPage: true }); + + await page.locator('#httpPort').fill('8080'); + await page.locator('#httpsPort').fill('8443'); + await page.locator('#primaryButton').click(); + await page.waitForTimeout(300); + + await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/); + await page.screenshot({ path: 'tests/playwright/screenshots/step-2-database.png', fullPage: true }); + + await page.locator('#primaryButton').click(); + await page.waitForTimeout(300); + await page.screenshot({ path: 'tests/playwright/screenshots/step-3-domain.png', fullPage: true }); + + await page.locator('#appDomain').fill('appwrite.example.com'); + await page.locator('#emailCertificates').fill('admin@example.com'); + await page.locator('#primaryButton').click(); + await page.waitForTimeout(300); + await page.screenshot({ path: 'tests/playwright/screenshots/step-4-security.png', fullPage: true }); + + await page.locator('button:has-text("Review")').click(); + await page.waitForTimeout(300); + await page.screenshot({ path: 'tests/playwright/screenshots/step-5-review.png', fullPage: true }); + await page.locator('#reviewContent').screenshot({ path: 'tests/playwright/screenshots/step-5-review-detail.png' }); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(300); + await page.screenshot({ path: 'tests/playwright/screenshots/mobile-view.png', fullPage: true }); + }); + + test('capture validation states', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await page.locator('#httpPort').fill('invalid'); + await page.locator('#primaryButton').click(); + await page.waitForTimeout(300); + + await expect(page.locator('.alert.is-error')).toBeVisible(); + await page.screenshot({ path: 'tests/playwright/screenshots/validation-error.png', fullPage: true }); + }); +}); diff --git a/tests/playwright/installer.spec.js b/tests/playwright/installer.spec.js new file mode 100644 index 0000000000..bb0cb3514f --- /dev/null +++ b/tests/playwright/installer.spec.js @@ -0,0 +1,132 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +test.describe('Appwrite Web Installer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('loads installer wizard shell', async ({ page }) => { + await expect(page).toHaveTitle(/Appwrite Installation/); + await expect(page.locator('h1')).toContainText('Appwrite Installation'); + await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 1/i); + }); + + test('shows configuration fields by default', async ({ page }) => { + await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/); + await expect(page.locator('#httpPort')).toHaveValue('80'); + await expect(page.locator('#httpsPort')).toHaveValue('443'); + }); + + test('validates port numbers on step 1', async ({ page }) => { + await page.locator('#httpPort').fill('invalid'); + await page.locator('#primaryButton').click(); + await expect(page.locator('.alert.is-error')).toBeVisible(); + await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/); + }); + + test('advances to database step with valid ports', async ({ page }) => { + await page.locator('#httpPort').fill('8080'); + await page.locator('#httpsPort').fill('8443'); + await page.locator('#primaryButton').click(); + + await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/); + await expect(page.locator('.database-option')).toHaveCount(2); + await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 2/i); + }); + + test('moves to domain step after database selection', async ({ page }) => { + await page.locator('#primaryButton').click(); + await page.locator('#primaryButton').click(); + + await expect(page.locator('.step[data-step="3"]')).toHaveClass(/active/); + await expect(page.locator('#appDomain')).toBeVisible(); + await expect(page.locator('#emailCertificates')).toBeVisible(); + await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 3/i); + }); + + test('validates domain fields', async ({ page }) => { + await page.locator('#primaryButton').click(); + await page.locator('#primaryButton').click(); + + await page.locator('#appDomain').fill(''); + await page.locator('#primaryButton').click(); + await expect(page.locator('.alert.is-error')).toContainText(/hostname/i); + + await page.locator('#appDomain').fill('appwrite.example.com'); + await page.locator('#emailCertificates').fill('invalid'); + await page.locator('#primaryButton').click(); + await expect(page.locator('.alert.is-error')).toContainText(/valid email/i); + }); + + test('moves to security step with valid domain data', async ({ page }) => { + await page.locator('#primaryButton').click(); + await page.locator('#primaryButton').click(); + await page.locator('#appDomain').fill('appwrite.example.com'); + await page.locator('#emailCertificates').fill('admin@example.com'); + await page.locator('#primaryButton').click(); + + await expect(page.locator('.step[data-step="4"]')).toHaveClass(/active/); + const keyValue = await page.locator('#opensslKey').inputValue(); + expect(keyValue.length).toBeGreaterThan(32); + await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 4/i); + }); + + test('shows review step before installation', async ({ page }) => { + await page.locator('#primaryButton').click(); + await page.locator('#primaryButton').click(); + await page.locator('#appDomain').fill('appwrite.example.com'); + await page.locator('#emailCertificates').fill('admin@example.com'); + await page.locator('#primaryButton').click(); + await page.locator('button:has-text("Review")').click(); + + await expect(page.locator('.step[data-step="5"]')).toHaveClass(/active/); + await expect(page.locator('#reviewContent')).toContainText('appwrite.example.com'); + await expect(page.locator('#reviewContent')).toContainText('admin@example.com'); + await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 5/i); + }); + + test('allows navigating backwards', async ({ page }) => { + await page.locator('#primaryButton').click(); + await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/); + await page.locator('#backButton').click(); + await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/); + }); + + test('prepares primary action before installation', async ({ page }) => { + await page.locator('#httpPort').fill('8080'); + await page.locator('#httpsPort').fill('8443'); + await page.locator('#primaryButton').click(); + await page.locator('#primaryButton').click(); + await page.locator('#appDomain').fill('appwrite.local'); + await page.locator('#emailCertificates').fill('admin@appwrite.local'); + await page.locator('#primaryButton').click(); + await page.locator('button:has-text("Review")').click(); + + await expect(page.locator('#reviewContent')).toContainText('8080'); + await expect(page.locator('#reviewContent')).toContainText('appwrite.local'); + await expect(page.locator('button:has-text("Install Appwrite")')).toBeVisible(); + }); + + test('keeps installation log hidden until triggered', async ({ page }) => { + await page.locator('#primaryButton').click(); + await page.locator('#primaryButton').click(); + await page.locator('#appDomain').fill('appwrite.test'); + await page.locator('#emailCertificates').fill('test@appwrite.test'); + await page.locator('#primaryButton').click(); + await page.locator('button:has-text("Review")').click(); + + await expect(page.locator('#installationLog')).toBeHidden(); + }); + + test('loads Pink design tokens', async ({ page }) => { + const stylesheets = await page.evaluate(() => Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(link => link.getAttribute('href'))); + expect(stylesheets.some(href => href && href.includes('pink'))).toBe(true); + }); +}); + +test.describe('Installer API', () => { + test.skip('API tests require the install task to be running', () => { + // Intentionally skipped pending API harness for installer. + }); +}); From bdb8bc081f18db8923b95d5c9e63576325726e83 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 21 Jan 2026 18:09:46 +1300 Subject: [PATCH 02/46] Add doc --- docs/WEB_INSTALLER.md | 261 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/WEB_INSTALLER.md diff --git a/docs/WEB_INSTALLER.md b/docs/WEB_INSTALLER.md new file mode 100644 index 0000000000..e468d16599 --- /dev/null +++ b/docs/WEB_INSTALLER.md @@ -0,0 +1,261 @@ +# Appwrite Web Installer + +The Appwrite Web Installer provides a modern, user-friendly web interface for installing Appwrite. It runs in a temporary Docker container with its own web server before the Appwrite stack is created. + +## Architecture + +The web installer runs as part of the install task in a lightweight PHP built-in web server: + +``` +docker run (temporary container) + └── Install Task (PHP) + └── Built-in Web Server (port 80) + ├── Serves installer UI + ├── Handles form submission + ├── Creates docker-compose.yml and .env + ├── Starts Appwrite containers + └── Shuts down automatically +``` + +## Features + +- **Self-contained**: Runs in its own temporary container before Appwrite starts +- **No dependencies**: Uses PHP's built-in server, no external services needed +- **Step-by-step guided installation**: Walk through server configuration, domain setup, and security settings +- **Pink UI Design System**: Consistent with Appwrite Console design +- **Input validation**: Real-time validation of all configuration parameters +- **Auto-generated secrets**: Secure random keys generated automatically +- **Progress tracking**: Visual progress bar showing installation progress +- **Responsive design**: Works on desktop and mobile devices +- **Dark mode support**: Automatic theme detection + +## Installation Steps + +The installer guides you through 5 steps: + +1. **Server Configuration**: Configure HTTP and HTTPS ports +2. **Domain Configuration**: Set up your Appwrite hostname and SSL certificate email +3. **Security Configuration**: Review auto-generated encryption keys +4. **Review & Install**: Review your configuration before installation +5. **Complete**: Installation success confirmation + +## Usage + +### Starting the Installer + +Run the Docker install command to launch the web installer: + +```bash +docker run -it --rm \ + -p 80:80 \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ + --entrypoint="install" \ + appwrite/appwrite:1.7.4 +``` + +This will: +1. Start a temporary container +2. Launch a web server on port 80 +3. **Automatically open your browser** to the installer at http://localhost + +### Using the Installer + +1. The installer opens automatically in your default browser +2. Fill in the installation form: + - HTTP Port (default: 80) + - HTTPS Port (default: 443) + - Appwrite Hostname (e.g., appwrite.example.com) + - SSL Certificate Email + - Secret Key (auto-generated) +3. Click through each step +4. Review your configuration +5. Click "Install Appwrite" +6. Wait for installation to complete +7. The web server will shut down automatically +8. Access your Appwrite console at `http://localhost/console` + +### CLI Mode Fallback + +You can still use the traditional CLI mode by passing `--interactive N`: + +```bash +docker run -it --rm \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ + --entrypoint="install" \ + appwrite/appwrite:1.7.4 \ + --interactive N +``` + +**Note:** In CLI mode, the `-p 80:80` flag is not needed. + +## Testing + +### Prerequisites + +The web installer needs to be running to execute the full test suite: + +```bash +# Terminal 1: Start the installer +docker run -it --rm \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ + -p 80:80 \ + --entrypoint="install" \ + appwrite/appwrite:1.7.4 + +# Terminal 2: Run tests +npm install +npx playwright install +npm run test:installer +``` + +### Running Tests + +```bash +# Run all installer tests +npm run test:installer + +# Run tests in headed mode (see browser) +npm run test:headed + +# Run tests with UI mode +npm run test:ui + +# Run all tests +npm test +``` + +### Test Coverage + +The test suite includes: + +- Page loading and rendering +- Step-by-step navigation +- Form validation (ports, email, required fields) +- Progress bar updates +- Back button functionality +- Complete installation flow +- API endpoint testing +- Pink UI integration verification + +## Files + +### Frontend + +- **`app/views/install/installer.phtml`**: Single-file HTML interface with inline JavaScript and CSS + +### Backend + +- **`src/Appwrite/Platform/Tasks/Install.php`**: Install task with embedded web server + - `startWebServer()`: Launches PHP built-in server + - `openBrowser()`: Opens browser automatically based on OS + - `createRouterScript()`: Generates router for handling HTTP requests + - `performInstallation()`: Public method that executes the actual installation +- **`app/views/install/compose.phtml`**: Docker Compose template +- **`app/views/install/env.phtml`**: Environment variables template + +### Tests + +- **`tests/playwright/installer.spec.js`**: Playwright E2E tests +- **`playwright.config.js`**: Playwright configuration + +## Technical Details + +### Web Server + +The installer uses PHP's built-in web server for simplicity: + +```php +php -S 0.0.0.0:80 /tmp/appwrite-installer-router.php +``` + +### Router Script + +A temporary router script is generated that handles: + +- `GET /` - Serves the single-file installer UI (HTML + inline CSS + inline JavaScript) +- `POST /install` - Handles installation requests by calling `performInstallation()` directly + +### Installation Flow + +1. User fills out form in browser +2. Form data is sent via POST to `/install` +3. Router script validates and processes the request +4. Calls `performInstallation()` public method directly (no reflection needed) +5. Generates `docker-compose.yml` and `.env` files +6. Starts Docker containers +7. Returns success response +8. Server shuts down after 2 seconds (allowing response to be sent) + +### Port Configuration + +- **Installer web server**: `80` (temporary, for installation only) +- **Appwrite HTTP**: `80` (default, configurable after installation) +- **Appwrite HTTPS**: `443` (default, configurable after installation) + +## Security Considerations + +- The installer generates secure random keys using `random_bytes()` +- All secrets are transmitted within the Docker network +- The temporary web server shuts down after installation +- Generated keys should be backed up securely +- The installer should only be run during initial setup + +## Troubleshooting + +### Installer Web Server Won't Start + +- Check that port 80 is available +- Verify PHP is available in the Docker image +- Check Docker logs for errors + +### Can't Access http://localhost + +- Ensure you're exposing port 80: Add `-p 80:80` to docker run command +- Check firewall settings +- Try `http://127.0.0.1` instead + +### Installation Fails + +- Check Docker is installed and running +- Verify write permissions for the appwrite directory +- Review browser console for errors +- Check Docker socket is mounted correctly + +### Tests Failing + +- Ensure the installer web server is running +- Verify Playwright browsers are installed: `npx playwright install` +- Check that port 80 is available +- Review test output for specific failures + +## Advantages Over CLI Installer + +1. **Better UX**: Visual interface instead of text prompts +2. **Validation**: Real-time field validation +3. **Review**: See all settings before committing +4. **Accessibility**: Works on any device with a browser +5. **Modern**: Consistent with Appwrite Console design + +## Future Enhancements + +Potential improvements: + +- [ ] Multi-language support +- [ ] Advanced configuration options +- [ ] Installation progress streaming +- [ ] Backup and restore existing installations +- [ ] One-click update functionality +- [ ] Pre-flight checks (Docker, ports, etc.) +- [ ] Custom theme support +- [ ] Installation templates + +## Support + +For issues or questions: + +- [GitHub Issues](https://github.com/appwrite/appwrite/issues) +- [Discord Community](https://appwrite.io/discord) +- [Documentation](https://appwrite.io/docs) From dd16ead89852815c30886c097f8e37c0eb81e5b4 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 22 Jan 2026 17:28:23 +0530 Subject: [PATCH 03/46] update: new installer ui. --- app/views/install/installer.phtml | 922 ++-------- app/views/install/installer/css/styles.css | 1501 +++++++++++++++ .../install/installer/icons/appwrite-logo.svg | 13 + .../install/installer/icons/chevron-down.svg | 3 + app/views/install/installer/icons/copy.svg | 4 + .../installer/icons/exclamation-circle.svg | 3 + .../installer/icons/indicator-active.svg | 3 + .../installer/icons/indicator-inactive.svg | 3 + app/views/install/installer/icons/info.svg | 3 + .../install/installer/icons/install-bg-1.svg | 16 + .../install/installer/icons/install-bg-2.svg | 16 + .../install/installer/icons/install-check.svg | 4 + .../installer/icons/install-spinner.svg | 4 + app/views/install/installer/icons/lock.svg | 3 + app/views/install/installer/icons/mariadb.svg | 10 + app/views/install/installer/icons/mongodb.svg | 6 + app/views/install/installer/icons/refresh.svg | 3 + .../install/installer/icons/step-dot.svg | 3 + app/views/install/installer/icons/warning.svg | 3 + app/views/install/installer/js/constants.js | 25 + app/views/install/installer/js/installer.js | 442 +++++ app/views/install/installer/js/steps.js | 1635 +++++++++++++++++ .../installer/templates/steps/step-1.phtml | 169 ++ .../installer/templates/steps/step-2.phtml | 58 + .../installer/templates/steps/step-3.phtml | 72 + .../installer/templates/steps/step-4.phtml | 49 + app/views/install/router.php | 257 +++ composer.json | 7 +- src/Appwrite/Platform/Tasks/Install.php | 821 ++++++++- src/Appwrite/Platform/Tasks/Upgrade.php | 5 +- 30 files changed, 5179 insertions(+), 884 deletions(-) create mode 100644 app/views/install/installer/css/styles.css create mode 100644 app/views/install/installer/icons/appwrite-logo.svg create mode 100644 app/views/install/installer/icons/chevron-down.svg create mode 100644 app/views/install/installer/icons/copy.svg create mode 100644 app/views/install/installer/icons/exclamation-circle.svg create mode 100644 app/views/install/installer/icons/indicator-active.svg create mode 100644 app/views/install/installer/icons/indicator-inactive.svg create mode 100644 app/views/install/installer/icons/info.svg create mode 100644 app/views/install/installer/icons/install-bg-1.svg create mode 100644 app/views/install/installer/icons/install-bg-2.svg create mode 100644 app/views/install/installer/icons/install-check.svg create mode 100644 app/views/install/installer/icons/install-spinner.svg create mode 100644 app/views/install/installer/icons/lock.svg create mode 100644 app/views/install/installer/icons/mariadb.svg create mode 100644 app/views/install/installer/icons/mongodb.svg create mode 100644 app/views/install/installer/icons/refresh.svg create mode 100644 app/views/install/installer/icons/step-dot.svg create mode 100644 app/views/install/installer/icons/warning.svg create mode 100644 app/views/install/installer/js/constants.js create mode 100644 app/views/install/installer/js/installer.js create mode 100644 app/views/install/installer/js/steps.js create mode 100644 app/views/install/installer/templates/steps/step-1.phtml create mode 100644 app/views/install/installer/templates/steps/step-2.phtml create mode 100644 app/views/install/installer/templates/steps/step-3.phtml create mode 100644 app/views/install/installer/templates/steps/step-4.phtml create mode 100644 app/views/install/router.php diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index 793b5b9b96..fa7bb467c2 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -1,6 +1,46 @@ @@ -8,825 +48,91 @@ $lockedDatabase = $lockedDatabase ?? null; <?php echo $isUpgrade ? 'Appwrite Upgrade' : 'Appwrite Installation'; ?> - - - + + + + - -
- + + data-locked-database="" + + data-default-http-port="" + data-default-https-port="" + data-default-app-domain="" + data-default-email-certificates="" + data-default-secret-key="" + data-default-assistant-openai-key="" + data-default-database="" + + data-dev-mode="true" + +> + -
-
-
-
-
- - +
+ + +
+ +
+ + diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css new file mode 100644 index 0000000000..c3531f7669 --- /dev/null +++ b/app/views/install/installer/css/styles.css @@ -0,0 +1,1501 @@ +/* Installer Styles - Pink v2 tokens and components */ + +:root { + /* Neutral colors */ + --neutral-0: rgba(255, 255, 255, 1); + --neutral-25: rgba(250, 250, 251, 1); + --neutral-40: rgba(244, 244, 247, 1); + --neutral-50: rgba(237, 237, 240, 1); + --neutral-100: rgba(228, 228, 231, 1); + --neutral-200: rgba(216, 216, 219, 1); + --neutral-300: rgba(173, 173, 176, 1); + --neutral-400: rgba(151, 151, 155, 1); + --neutral-500: rgba(129, 129, 134, 1); + --neutral-700: rgba(86, 86, 92, 1); + --neutral-800: rgba(45, 45, 49, 1); + --neutral-900: rgba(25, 25, 28, 1); + + /* Warning colors */ + --web-orange-200: rgba(255, 213, 194, 1); + --web-orange-500: rgba(254, 124, 67, 1); + --web-orange-700: rgba(97, 37, 10, 1); + + /* Error colors */ + --web-red-500: rgba(255, 69, 58, 1); + --web-red-700: rgba(179, 18, 18, 1); + + /* Brand colors */ + --brand-pink-500: rgba(253, 54, 110, 1); + --brand-pink-600: rgba(202, 43, 88, 1); + --brand-pink-700: rgba(152, 32, 66, 1); + + /* Background colors */ + --bgcolor-neutral-default: var(--neutral-25); + --bgcolor-neutral-primary: var(--neutral-0); + --bgcolor-neutral-secondary: var(--neutral-40); + --bgcolor-neutral-tertiary: var(--neutral-50); + --bgcolor-neutral-invert-weaker: var(--neutral-500); + --bgcolor-neutral-invert-weak: var(--neutral-700); + --bgcolor-success-weak: rgba(16, 185, 129, 0.16); + --bgcolor-accent: var(--brand-pink-500); + --bgcolor-accent-secondary: var(--brand-pink-600); + --bgcolor-accent-tertiary: var(--brand-pink-700); + --bgcolor-warning-weaker: rgba(254, 124, 67, 0.04); + --bgcolor-warning-weak: rgba(254, 124, 67, 0.16); + --bgcolor-error-weaker: rgba(255, 69, 58, 0.04); + + /* Foreground colors */ + --fgcolor-neutral-primary: var(--neutral-800); + --fgcolor-neutral-secondary: var(--neutral-700); + --fgcolor-neutral-tertiary: var(--neutral-400); + --fgcolor-neutral-weak: var(--neutral-200); + --fgcolor-accent: var(--brand-pink-500); + --fgcolor-on-accent: var(--neutral-0); + --fgcolor-on-invert: var(--neutral-25); + --fgcolor-on-success-weak: rgba(10, 113, 79, 1); + --fgcolor-warning: rgba(97, 37, 10, 1); + --fgcolor-on-warning-weak: var(--web-orange-700); + --fgcolor-success: rgba(16, 185, 129, 1); + --fgcolor-error: var(--web-red-700); + + /* Border colors */ + --border-neutral: var(--neutral-50); + --border-neutral-strong: var(--neutral-200); + --border-neutral-stronger: var(--neutral-500); + --border-focus: var(--neutral-300); + --border-accent: var(--brand-pink-500); + --border-warning-weak: rgba(254, 124, 67, 0.32); + --border-error: var(--web-red-500); + --border-error-weak: rgba(255, 69, 58, 0.32); + + /* Overlay colors */ + --overlay-neutral-hover: rgba(25, 25, 28, 0.03); + --overlay-neutral-pressed: rgba(25, 25, 28, 0.04); + + /* Icon sizes */ + --icon-size-s: var(--base-16); + + /* Typography */ + --font-family-brand: 'Aeonik Pro'; + --font-family-sansserif: 'Inter'; + --font-family-code: 'Fira Code'; + --sans-fallbacks: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --mono-fallbacks: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + + --font-size-xs: 12px; + --font-size-s: 14px; + --font-size-m: 16px; + --font-size-l: 20px; + --label-line-height: 19.6px; + + /* Motion */ + --duration-fast: 150ms; + --duration-short: 160ms; + --duration-medium: 200ms; + --duration-extended: 250ms; + --duration-slow: 300ms; + --ease-standard: ease; + --ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1); + --ease-in-out: ease-in-out; + --ease-out: ease-out; + + /* Spacing */ + --base-0: 0px; + --base-2: 2px; + --base-4: 4px; + --base-6: 6px; + --base-8: 8px; + --base-10: 10px; + --base-12: 12px; + --base-16: 16px; + --base-20: 20px; + --base-24: 24px; + --base-32: 32px; + --base-40: 40px; + --base-48: 48px; + + /* Component sizing */ + --button-min-width-s: 60px; + + --space-0: var(--base-0); + --space-1: var(--base-2); + --space-2: var(--base-4); + --space-3: var(--base-6); + --space-4: var(--base-8); + --space-5: var(--base-10); + --space-6: var(--base-12); + --space-7: var(--base-16); + --space-8: var(--base-20); + --space-9: var(--base-24); + --space-10: var(--base-32); + --space-11: var(--base-40); + --space-12: var(--base-48); + + /* Gap scale (Pink tokens) */ + --gap-none: var(--base-0); + --gap-xxxs: var(--base-2); + --gap-xxs: var(--base-4); + --gap-xs: var(--base-6); + --gap-s: var(--base-8); + --gap-m: var(--base-12); + --gap-l: var(--base-16); + --gap-xl: var(--base-20); + --gap-xxl: var(--base-32); + --gap-xxxl: var(--base-40); + + /* Border radius */ + --border-radius-s: 8px; + --border-radius-m: 12px; + --border-radius-l: 16px; + + /* Border widths */ + --border-width-s: 1px; + --border-width-l: 2px; + + /* Installer layout vars */ + --step-min-height: auto; + --divider-gap-top: var(--gap-xl); + + /* Legacy aliases */ + --bgColor-neutral-default: var(--bgcolor-neutral-default); + --bgColor-neutral-primary: var(--bgcolor-neutral-primary); + --bgColor-accent: var(--bgcolor-accent); + --fgColor-neutral-primary: var(--fgcolor-neutral-primary); + --fgColor-neutral-secondary: var(--fgcolor-neutral-secondary); + --fgColor-neutral-tertiary: var(--fgcolor-neutral-tertiary); + --fgColor-neutral-weak: var(--fgcolor-neutral-weak); + --fgColor-accent: var(--fgcolor-accent); + --fgColor-on-accent: var(--fgcolor-on-accent); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + color: var(--fgcolor-neutral-primary); + background: var(--bgcolor-neutral-default); +} + +.installer-page { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-7); + gap: var(--gap-l); + background: var(--bgcolor-neutral-default); + position: relative; + overflow: hidden; +} + +.installer-main { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; + min-height: 0; +} + +.installer-backdrop { + position: fixed; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-medium) var(--ease-standard); + z-index: 0; +} + +.installer-page[data-step='4'] .installer-backdrop { + opacity: 1; +} + +.installer-blob { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.installer-blob.blob-one { + transform: translate(-50%, -50%) translate(-111px, -100px); +} + +.installer-blob.blob-two { + transform: translate(-50%, -50%) translate(-278px, -107px); +} + +.installer-card { + width: 100%; + max-width: 500px; + max-height: 100%; + padding: var(--space-8); + background: var(--bgcolor-neutral-primary); + border-radius: var(--border-radius-l); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: -1px; + display: flex; + flex-direction: column; + gap: 0; + transition: opacity var(--duration-medium) var(--ease-standard); + overflow: hidden; +} + +.installer-page[data-step='4'] .installer-card { + opacity: 0; + pointer-events: none; +} + +.installer-page[data-install-locked='true'] .step-indicators { + 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; +} + +.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; +} + +.installer-step { + display: grid; + position: relative; + width: 100%; + min-height: var(--step-min-height, auto); + flex: 1 1 auto; + overflow: hidden; +} + +.action-shell { + display: flex; + flex-direction: column; + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +.install-screen { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-medium) var(--ease-standard); + z-index: 1; +} + +.installer-page[data-step='4'] .install-screen { + opacity: 1; + pointer-events: auto; +} + +.install-screen-content { + width: 100%; + max-width: 500px; +} + +.step-panel { + grid-area: 1 / 1; + width: 100%; + opacity: 1; + transition: opacity var(--duration-medium) var(--ease-standard); + will-change: opacity; +} + +.step-panel:not(.is-measure) { + height: 100%; + overflow-y: auto; + scrollbar-gutter: stable; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.step-panel:not(.is-measure)::-webkit-scrollbar { + width: 0; + height: 0; +} + +.step-panel.is-entering { + opacity: 0; + pointer-events: none; +} + +.step-panel.is-exiting { + opacity: 0; + pointer-events: none; +} + +.step-panel.is-measure { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.stack-none { + display: flex; + flex-direction: column; + gap: var(--gap-none); +} + +.stack-xxxs { + display: flex; + flex-direction: column; + gap: var(--gap-xxxs); +} + +.stack-xxs { + display: flex; + flex-direction: column; + gap: var(--gap-xxs); +} + +.stack-xs { + display: flex; + flex-direction: column; + gap: var(--gap-xs); +} + +.stack-s { + display: flex; + flex-direction: column; + gap: var(--gap-s); +} + +.stack-m { + display: flex; + flex-direction: column; + gap: var(--gap-m); +} + +.stack-l { + display: flex; + flex-direction: column; + gap: var(--gap-l); +} + +.stack-xl { + display: flex; + flex-direction: column; + gap: var(--gap-xl); +} + +.stack-xxl { + display: flex; + flex-direction: column; + gap: var(--gap-xxl); +} + +.stack-xxxl { + display: flex; + flex-direction: column; + gap: var(--gap-xxxl); +} + +.install-layout { + width: 100%; +} + +.install-card { + width: 100%; + padding: var(--space-2); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-l); + border: var(--border-width-s) solid var(--border-neutral); +} + +.install-panel { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--gap-xxxs); + transition: height var(--duration-medium) var(--ease-standard); +} + +.install-header { + padding: var(--space-4); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--gap-xxxs); +} + +.install-list { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; +} + +.install-row { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0; + background: var(--bgcolor-neutral-primary); + border: var(--border-width-s) solid var(--border-neutral); + border-radius: 0; + overflow: hidden; + opacity: 1; + transform: translateY(0); + transition: opacity var(--duration-medium) var(--ease-standard), + transform var(--duration-slow) var(--ease-emphasized); + will-change: transform, opacity; +} + +.install-row-main { + display: flex; + align-items: center; + gap: var(--gap-s); + padding: var(--space-3) var(--space-6); +} + +.install-row-label { + display: flex; + align-items: center; + gap: var(--gap-s); + min-width: 0; + min-height: 32px; + padding-block-start: var(--space-2); +} + +.install-text { + min-width: 0; +} + +.install-row-toggle { + margin-left: auto; + width: 32px; + height: 32px; + border: none; + border-radius: var(--border-radius-xs); + background: transparent; + color: var(--fgcolor-neutral-secondary); + display: none; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: background-color var(--duration-short) var(--ease-standard), + color var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard); +} + +.install-row-toggle svg { + width: 20px; + height: 20px; +} + + +.install-row-details { + max-height: none; + opacity: 0; + overflow: hidden; + display: grid; + grid-template-rows: 0fr; + padding: 0; + border: none; + transition: grid-template-rows var(--duration-medium) var(--ease-standard), + opacity var(--duration-medium) var(--ease-standard); +} + +.install-row:first-child { + border-top-left-radius: var(--border-radius-m); + border-top-right-radius: var(--border-radius-m); +} + +.install-row + .install-row { + margin-top: -1px; +} + +.install-row:last-child { + border-bottom-left-radius: var(--border-radius-m); + border-bottom-right-radius: var(--border-radius-m); +} + +.install-row.is-entering { + opacity: 0; + transform: translateY(-12px); +} + +.install-icon { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.install-icon-spinner, +.install-icon-check { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + transition: opacity var(--duration-fast) var(--ease-standard); +} + +.install-icon-error { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-standard); + color: var(--fgcolor-error); +} + +.install-icon-check { + opacity: 0; +} + +.install-icon-spinner svg { + animation: install-spin 1s linear infinite; +} + +.install-row[data-status='completed'] .install-icon-spinner { + opacity: 0; +} + +.install-row[data-status='completed'] .install-icon-spinner svg { + animation: none; +} + +.install-row[data-status='completed'] .install-icon-check { + opacity: 1; +} + +.install-icon-check .check-bg { + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-standard); +} + +.install-icon-check .check-mark { + stroke-dasharray: 14; + stroke-dashoffset: 14; + transition: stroke-dashoffset var(--duration-medium) var(--ease-standard); +} + +.install-row[data-status='completed'] .install-icon-check .check-bg { + opacity: 1; +} + +.install-row[data-status='completed'] .install-icon-check .check-mark { + stroke-dashoffset: 0; + transition-delay: 40ms; +} + +.install-row[data-status='error'] { + height: auto; + overflow: hidden; +} + +.install-row[data-status='error'] .install-icon-spinner, +.install-row[data-status='error'] .install-icon-check { + opacity: 0; +} + +.install-row[data-status='error'] .install-icon-error { + opacity: 1; +} + +.install-row[data-status='error'] .install-row-toggle { + opacity: 1; + pointer-events: auto; + display: inline-flex; +} + +.install-row[data-status='error'] { + cursor: pointer; +} + +.install-row[data-status='error'] .button { + cursor: pointer; +} + +.install-row.is-open .install-row-toggle { + transform: rotate(180deg); +} + +.install-row.is-open .install-row-details { + grid-template-rows: 1fr; + opacity: 1; +} + +.install-row-details-inner { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0; + border-top: var(--border-width-s) solid var(--border-neutral); + border-left: none; + border-right: none; + border-radius: 0; + background: var(--bgcolor-neutral-primary); +} + +.install-error-code { + margin: 0; + padding: var(--space-4) var(--space-6); + width: 100%; + background: var(--bgcolor-neutral-default); + border: none; + border-radius: 0; + font-family: var(--font-family-code), var(--mono-fallbacks); + font-size: var(--font-size-xs); + line-height: 140%; + letter-spacing: 0; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + max-height: 160px; + overflow-y: auto; + overflow-x: auto; + color: var(--fgcolor-neutral-primary); + scrollbar-width: none; + -ms-overflow-style: none; +} + +.install-error-code::-webkit-scrollbar { + width: 0; + height: 0; +} + + +.install-error-actions { + padding: var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-primary); + border-top: var(--border-width-s) solid var(--border-neutral); + border-radius: 0 0 var(--border-radius-m) var(--border-radius-m); + display: flex; + justify-content: flex-end; +} + +.install-error-details .button { + align-self: center; + margin-top: 0; +} + +@keyframes install-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.typography-title-s { + font-family: var(--font-family-brand), var(--sans-fallbacks); + font-size: var(--font-size-l); + font-weight: 400; + line-height: 130%; + letter-spacing: -0.144px; +} + +.typography-title-s, +.typography-text-m-400, +.typography-text-m-500 { + margin: 0; +} + +.typography-text-m-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + font-size: var(--font-size-s); + font-weight: 400; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-text-m-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + font-size: var(--font-size-s); + font-weight: 500; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-text-xs-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + font-size: var(--font-size-xs); + font-weight: 400; + line-height: 130%; + letter-spacing: -0.12px; +} + +.typography-text-xs-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 130%; + letter-spacing: -0.12px; +} + +.typography-caption-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + font-size: var(--font-size-xs); + font-weight: 400; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-caption-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks); + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 140%; + letter-spacing: -0.063px; +} + +.label-text { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.label-optional { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-1); +} + +.label-info-button { + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + color: var(--fgcolor-neutral-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; +} + +.label-info-button svg { + width: 16px; + height: 16px; +} + +.text-neutral-primary { + color: var(--fgcolor-neutral-primary); +} + +.text-neutral-secondary { + color: var(--fgcolor-neutral-secondary); +} + +.text-neutral-tertiary { + color: var(--fgcolor-neutral-tertiary); +} + +.text-warning { + color: var(--fgcolor-warning); +} + +.text-error { + color: var(--fgcolor-error); +} + +.text-on-success-weak { + color: var(--fgcolor-on-success-weak); +} + +.text-on-invert { + color: var(--fgcolor-on-invert); +} + +.input-group { + width: 100%; +} + +.field-error { + display: flex; + align-items: flex-start; + gap: var(--space-3); + margin-top: var(--space-2); + color: var(--fgcolor-error); + max-height: 0; + opacity: 0; + overflow: hidden; + padding-inline-start: var(--space-2); + transition: max-height var(--duration-extended) var(--ease-standard), + opacity var(--duration-extended) var(--ease-standard); +} + +.field-error-text { + line-height: 1.4; +} + +.field-error.is-visible { + max-height: 64px; + opacity: 1; +} + +.input-field { + width: 100%; + padding: var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-default); + border: var(--border-width-s) solid var(--border-neutral); + outline: none; + border-radius: var(--border-radius-s); + transition: all var(--duration-fast) var(--ease-in-out); +} + +.input-field:focus { + border-color: var(--border-focus); + box-shadow: inset 0 0 0 1px var(--border-focus); +} + +.input-field.is-error { + border-color: var(--border-error); + box-shadow: none; +} + +.input-field.is-error:focus { + border-color: var(--border-error); + box-shadow: inset 0 0 0 1px var(--border-error); +} + +.input-field::placeholder { + color: var(--fgcolor-neutral-tertiary); +} + +.input-field[type='number'] { + appearance: textfield; + -moz-appearance: textfield; +} + +.input-field[type='number']::-webkit-outer-spin-button, +.input-field[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.selector-group { + display: flex; + gap: var(--gap-m); + width: 100%; + position: relative; +} + +.selector-card { + position: relative; + flex: 1; + min-height: 52px; + display: flex; + align-items: center; + gap: var(--gap-s); + padding: var(--space-4) var(--space-6); + background: var(--bgcolor-neutral-default); + border: none; + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral); + border-radius: var(--border-radius-s); + cursor: pointer; + overflow: hidden; + transition: all var(--duration-fast) var(--ease-in-out); +} + +.selector-card::before { + content: ''; + position: absolute; + inset: 0; + background: var(--overlay-neutral-hover); + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-in-out); +} + +.selector-card:hover::before, +.selector-card.selected::before { + opacity: 1; +} + +.selector-card.selected { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger); +} + +.selector-card:focus-within { + outline: none; + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral), + inset 0 0 0 var(--border-width-l) var(--border-focus); +} + +.selector-card.selected:focus-within { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger), + inset 0 0 0 var(--border-width-l) var(--border-focus); +} + +.selector-content { + position: relative; + display: flex; + flex-direction: column; + gap: 0; + flex: 1; +} + +.selector-icon { + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.accordion { + display: flex; + flex-direction: column; + gap: 0; +} + +.accordion-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-s); + width: 100%; + padding: 0; + border: none; + border-radius: var(--border-radius-s); + background: transparent; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-in-out); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; +} + +.accordion-toggle:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); +} + +.accordion-chevron { + width: 20px; + height: 20px; + transition: transform var(--duration-slow) var(--ease-in-out); + color: var(--fgcolor-neutral-tertiary); +} + +.accordion-chevron[data-open='true'] { + transform: rotate(180deg); +} + +.accordion-content { + display: flex; + flex-direction: column; + gap: var(--gap-l); + width: 100%; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height var(--duration-medium) var(--ease-out), + opacity var(--duration-medium) var(--ease-out); +} + +.accordion-content.open { + opacity: 1; + overflow: visible; + margin-top: var(--gap-m); +} + +.divider { + width: 100%; + height: 1px; + background: var(--border-neutral); + margin-top: var(--divider-gap-top, var(--gap-xl)); + margin-bottom: var(--gap-xl); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--gap-xs); + padding: var(--space-3) var(--space-5); + min-height: var(--space-10); + min-width: var(--button-min-width-s); + border-radius: var(--border-radius-s); + border: none; + box-shadow: inset 0 0 0 var(--border-width-s) transparent; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-in-out); + outline-offset: var(--border-width-s); + background: transparent; + color: var(--fgcolor-neutral-secondary); +} + +.button-text { + display: inline-flex; + align-items: center; +} + +.button:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); +} + +.button.primary { + background: var(--bgcolor-accent); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent); + color: var(--fgcolor-on-accent); +} + +.button.primary:hover { + background: var(--bgcolor-accent-secondary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-secondary); +} + +.button.primary:active { + background: var(--bgcolor-accent-tertiary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-tertiary); +} + +.button.primary:disabled { + background: var(--bgcolor-neutral-invert-weaker); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-neutral-invert-weaker); +} + +.button.secondary { + background: var(--bgcolor-neutral-primary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral); + color: var(--fgcolor-neutral-primary); +} + +.button.secondary:hover { + background: var(--bgcolor-neutral-secondary); +} + +.button.secondary:active { + background: var(--bgcolor-neutral-tertiary); +} + +.button.secondary:disabled { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-strong); +} + +.button:disabled { + opacity: 0.4; + pointer-events: none; +} + +.action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-xl); + width: 100%; + flex-wrap: nowrap; +} + +.step-indicators { + display: flex; + align-items: center; + line-height: 0; +} + +.step-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} + +.step-indicator svg { + display: block; +} + +.step-indicator .indicator-active { + display: none; +} + +.step-indicator.is-active .indicator-active { + display: inline-flex; +} + +.step-indicator.is-active .indicator-inactive { + display: none; +} + +.step-indicator.is-hidden { + display: none; +} + +.step-layout { + display: flex; + flex-direction: column; + gap: 0; +} + +.inline-alert { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--gap-m); + width: 100%; + padding: var(--space-6); + background: var(--bgcolor-warning-weaker); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-warning-weak); + outline-offset: -1px; +} + +.inline-alert-content { + display: inline-flex; + align-items: flex-start; + gap: var(--gap-s); + align-self: stretch; +} + +.inline-alert-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--fgcolor-warning); + flex-shrink: 0; +} + +.inline-alert-text { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.input-action { + display: flex; + align-items: center; + gap: var(--gap-l); + width: 100%; + padding: var(--space-3) var(--space-5) var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-s); + border: var(--border-width-s) solid var(--border-neutral); + outline: none; + transition: all var(--duration-fast) var(--ease-in-out); +} + +.input-action:focus-within { + border-color: var(--border-focus); + box-shadow: inset 0 0 0 1px var(--border-focus); +} + +.input-action.is-error { + border-color: var(--border-error); + box-shadow: none; +} + +.input-action.is-error:focus-within { + border-color: var(--border-error); + box-shadow: inset 0 0 0 1px var(--border-error); +} + +.input-action-input { + flex: 1; + border: none; + background: transparent; + padding: 0; + outline: none; +} + +.input-action-buttons { + display: flex; + align-items: center; + gap: var(--gap-m); +} + +.tooltip-wrapper { + position: relative; + display: inline-flex; +} + +.tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%) translateY(8px); + display: inline-flex; + align-items: center; + justify-content: center; + width: max-content; + max-width: 11.25rem; + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-s); + background: var(--bgcolor-neutral-invert-weak); + color: var(--fgcolor-on-invert); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s linear var(--duration-short); + z-index: 5; +} + +.tooltip-assistant { + width: 246px; + max-width: 246px; + text-align: left; +} + +.tooltip-wrapper:hover .tooltip, +.tooltip-wrapper:focus-within .tooltip, +.tooltip-wrapper.is-open .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; +} + +.input-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--fgcolor-neutral-secondary); + cursor: pointer; +} + +.input-icon-button svg { + width: 16px; + height: 16px; + display: block; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + background: var(--bgcolor-neutral-primary); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-neutral-strong); + outline-offset: -1px; + border: none; + color: var(--fgcolor-neutral-tertiary); + cursor: pointer; +} + +.icon-button.is-rotating svg { + animation: installer-rotate-once 0.35s linear; +} + +.input-row { + display: flex; + align-items: flex-start; + gap: var(--space-4); + width: 100%; +} + +.input-row .input-group { + flex: 1; +} + +.input-row .button { + margin-top: calc(var(--label-line-height) + var(--space-3)); +} + +.input-row .icon-button { + margin-top: calc(var(--label-line-height) + var(--space-3)); +} + +.review-card { + width: 100%; + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-m); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: -1px; + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.review-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-m); +} + +.review-label { + text-align: right; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-1) var(--space-2); + border-radius: 6px; +} + +.badge-success { + background: var(--bgcolor-success-weak); + color: var(--fgcolor-on-success-weak); +} + +.badge-warning { + background: var(--bgcolor-warning-weak); + color: var(--fgcolor-on-warning-weak); +} + +.badge-neutral { + background: var(--bgcolor-neutral-tertiary); + color: var(--fgcolor-neutral-secondary); +} + +.installer-footer { + width: 100%; + display: flex; + justify-content: center; + padding-bottom: var(--space-7); + position: relative; + z-index: 1; +} + +.appwrite-logo { + width: 131px; + height: 25px; + position: relative; +} + +.appwrite-logo svg { + width: 100%; + height: 100%; + display: block; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} + +@keyframes installer-rotate-once { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .installer-page { + padding: var(--space-6); + gap: var(--gap-m); + } + + .installer-card { + padding: var(--space-6); + } + + .installer-step { + min-height: 0; + } + + .selector-group { + flex-direction: column; + } + + .input-row { + flex-direction: column; + align-items: stretch; + } + + .input-row .button { + align-self: flex-start; + margin-top: 0; + } + + .input-row .icon-button { + margin-top: 0; + } + + .step-layout[data-step='2'] .input-row { + flex-direction: row; + align-items: flex-end; + } + + .action-bar { + gap: var(--gap-s); + } +} diff --git a/app/views/install/installer/icons/appwrite-logo.svg b/app/views/install/installer/icons/appwrite-logo.svg new file mode 100644 index 0000000000..f0f2fa5af0 --- /dev/null +++ b/app/views/install/installer/icons/appwrite-logo.svg @@ -0,0 +1,13 @@ + diff --git a/app/views/install/installer/icons/chevron-down.svg b/app/views/install/installer/icons/chevron-down.svg new file mode 100644 index 0000000000..74d9c14cfd --- /dev/null +++ b/app/views/install/installer/icons/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/copy.svg b/app/views/install/installer/icons/copy.svg new file mode 100644 index 0000000000..008d89098a --- /dev/null +++ b/app/views/install/installer/icons/copy.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/exclamation-circle.svg b/app/views/install/installer/icons/exclamation-circle.svg new file mode 100644 index 0000000000..3416951e48 --- /dev/null +++ b/app/views/install/installer/icons/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/indicator-active.svg b/app/views/install/installer/icons/indicator-active.svg new file mode 100644 index 0000000000..e620bdf6e9 --- /dev/null +++ b/app/views/install/installer/icons/indicator-active.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/indicator-inactive.svg b/app/views/install/installer/icons/indicator-inactive.svg new file mode 100644 index 0000000000..78b5ef3ac0 --- /dev/null +++ b/app/views/install/installer/icons/indicator-inactive.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/info.svg b/app/views/install/installer/icons/info.svg new file mode 100644 index 0000000000..6f0524fb49 --- /dev/null +++ b/app/views/install/installer/icons/info.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/install-bg-1.svg b/app/views/install/installer/icons/install-bg-1.svg new file mode 100644 index 0000000000..7291974bee --- /dev/null +++ b/app/views/install/installer/icons/install-bg-1.svg @@ -0,0 +1,16 @@ + diff --git a/app/views/install/installer/icons/install-bg-2.svg b/app/views/install/installer/icons/install-bg-2.svg new file mode 100644 index 0000000000..9e49ddb99f --- /dev/null +++ b/app/views/install/installer/icons/install-bg-2.svg @@ -0,0 +1,16 @@ + diff --git a/app/views/install/installer/icons/install-check.svg b/app/views/install/installer/icons/install-check.svg new file mode 100644 index 0000000000..7d43d007c6 --- /dev/null +++ b/app/views/install/installer/icons/install-check.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/install-spinner.svg b/app/views/install/installer/icons/install-spinner.svg new file mode 100644 index 0000000000..543d2c4b22 --- /dev/null +++ b/app/views/install/installer/icons/install-spinner.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/lock.svg b/app/views/install/installer/icons/lock.svg new file mode 100644 index 0000000000..6a87c21345 --- /dev/null +++ b/app/views/install/installer/icons/lock.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/mariadb.svg b/app/views/install/installer/icons/mariadb.svg new file mode 100644 index 0000000000..921fbbb0dc --- /dev/null +++ b/app/views/install/installer/icons/mariadb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/views/install/installer/icons/mongodb.svg b/app/views/install/installer/icons/mongodb.svg new file mode 100644 index 0000000000..5470eccb38 --- /dev/null +++ b/app/views/install/installer/icons/mongodb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/install/installer/icons/refresh.svg b/app/views/install/installer/icons/refresh.svg new file mode 100644 index 0000000000..56f70b425a --- /dev/null +++ b/app/views/install/installer/icons/refresh.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/step-dot.svg b/app/views/install/installer/icons/step-dot.svg new file mode 100644 index 0000000000..490a039d20 --- /dev/null +++ b/app/views/install/installer/icons/step-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/views/install/installer/icons/warning.svg b/app/views/install/installer/icons/warning.svg new file mode 100644 index 0000000000..131b29171c --- /dev/null +++ b/app/views/install/installer/icons/warning.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js new file mode 100644 index 0000000000..e7c3256de2 --- /dev/null +++ b/app/views/install/installer/js/constants.js @@ -0,0 +1,25 @@ +(() => { + window.InstallerConstants = Object.freeze({ + stepTransitionMs: 260, + errorClearMs: 180, + installPollIntervalMs: 4000, + installFallbackDelayMs: 12000, + mockStepDelayMs: 1800, + progressTransitionDelayMs: 200, + mockErrorDetails: { + output: [ + 'Failed to start containers: appwrite-worker-webhooks', + 'Pulling appwrite-worker-databases', + 'Pulling appwrite-worker-audits', + 'Error response from daemon: manifest for appwrite/appwrite:local not found: manifest unknown', + 'appwrite-worker-webhooks Error context canceled', + 'appwrite-worker-databases Error context canceled' + ].join('\n'), + trace: [ + '#0 /usr/src/code/src/Appwrite/Platform/Tasks/Install.php(540): Appwrite\\\\Platform\\\\Tasks\\\\Install->performInstallation(...)', + '#1 /usr/src/code/src/Appwrite/Platform/Tasks/Install.php(910): Appwrite\\\\Platform\\\\Tasks\\\\Install->startWebServer(...)', + '#2 {main}' + ].join('\n') + } + }); +})(); diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js new file mode 100644 index 0000000000..ce372e04a0 --- /dev/null +++ b/app/views/install/installer/js/installer.js @@ -0,0 +1,442 @@ +(() => { + const stepContainer = document.querySelector('.installer-step'); + const installerCard = document.querySelector('.installer-card'); + const backButton = document.querySelector('[data-action="back"]'); + const nextButton = document.querySelector('[data-action="next"]'); + const installScreen = document.querySelector('.install-screen-content'); + const indicatorNodes = Array.from(document.querySelectorAll('.step-indicator')); + const STEP_TRANSITION_TIMEOUT = window.InstallerConstants?.stepTransitionMs ?? 260; + + if (!stepContainer || !installerCard) return; + + const isUpgrade = document.body?.dataset.upgrade === 'true'; + const stepFlow = isUpgrade ? [1, 3, 4] : [1, 2, 3, 4]; + const cardSteps = stepFlow.filter((step) => step !== 4); + + const normalizeStep = (step) => { + const numeric = clampStep(step); + if (stepFlow.includes(numeric)) return numeric; + if (numeric <= stepFlow[0]) return stepFlow[0]; + for (let i = 0; i < stepFlow.length; i += 1) { + if (numeric < stepFlow[i]) { + return stepFlow[i]; + } + } + return stepFlow[stepFlow.length - 1]; + }; + + const buildStepConfig = () => { + const config = {}; + stepFlow.forEach((step, index) => { + if (step === 4) { + config[step] = { back: { target: null }, next: { target: null } }; + return; + } + const prev = stepFlow[index - 1] ?? null; + const next = stepFlow[index + 1] ?? null; + const label = next === 4 ? (isUpgrade ? 'Upgrade' : 'Install') : 'Next'; + config[step] = { + back: { target: prev }, + next: { label, target: next } + }; + }); + return config; + }; + + const STEP_CONFIG = buildStepConfig(); + + const stepCache = new Map(); + let maxStepHeight = 0; + let isTransitioning = false; + let pendingStep = null; + let pendingPushState = false; + + const clampStep = (step) => Math.max(1, Math.min(4, step)); + const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.()); + + const scrollToFirstError = (panel) => { + if (!panel) return; + const getErrorNode = () => panel.querySelector('.field-error.is-visible') + || panel.querySelector('.field-error') + || panel.querySelector('.input-field.is-error, .input-action.is-error'); + const container = panel.closest('.step-panel') || panel; + const attemptScroll = () => { + const target = getErrorNode(); + if (!target || typeof target.getBoundingClientRect !== 'function') return false; + const targetRect = target.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const targetTop = targetRect.top - containerRect.top + container.scrollTop; + const targetBottom = targetTop + targetRect.height; + const viewTop = container.scrollTop; + const viewBottom = viewTop + containerRect.height; + const padding = 12; + + let nextScrollTop = viewTop; + if (targetTop < viewTop + padding) { + nextScrollTop = Math.max(0, targetTop - padding); + } else if (targetBottom > viewBottom - padding) { + nextScrollTop = Math.max(0, targetBottom - containerRect.height + padding); + } + + if (Math.abs(nextScrollTop - viewTop) < 1) { + return false; + } + + container.scrollTo({ top: nextScrollTop, behavior: 'smooth' }); + return true; + }; + + let remaining = 20; + let lastScrollTop = -1; + const settle = () => { + if (remaining <= 0) return; + const moved = attemptScroll(); + remaining -= 1; + const currentTop = container.scrollTop; + const delta = Math.abs(currentTop - lastScrollTop); + lastScrollTop = currentTop; + if (!moved && delta < 0.5) { + return; + } + requestAnimationFrame(settle); + }; + requestAnimationFrame(settle); + }; + + const getStepFromUrl = () => { + const url = new URL(window.location.href); + const step = Number(url.searchParams.get('step') || 1); + return normalizeStep(Number.isNaN(step) ? 1 : step); + }; + + const buildStepUrl = (step) => { + const url = new URL(window.location.href); + url.searchParams.set('step', step); + return url; + }; + + const setStepInUrl = (step, pushState) => { + const url = new URL(window.location.href); + url.searchParams.set('step', step); + + if (pushState) { + window.history.pushState({ step }, '', url.toString()); + } + + return url; + }; + + const updateActionBar = (step) => { + const config = STEP_CONFIG[step] || STEP_CONFIG[1]; + if (!backButton || !nextButton) return; + const locked = isInstallLocked(); + + const setButtonLabel = (button, label) => { + if (!button) return; + let text = button.querySelector('.button-text'); + if (!text) { + text = document.createElement('span'); + text.className = 'button-text typography-text-m-500'; + button.textContent = ''; + button.appendChild(text); + } + text.textContent = label; + }; + + if (!locked && config.back?.target) { + backButton.disabled = false; + backButton.setAttribute('data-step-target', String(config.back.target)); + } else { + backButton.disabled = true; + backButton.removeAttribute('data-step-target'); + } + setButtonLabel(backButton, 'Back'); + + if (!locked && config.next?.target) { + setButtonLabel(nextButton, config.next?.label || 'Next'); + nextButton.setAttribute('data-step-target', String(config.next?.target || 1)); + nextButton.disabled = false; + } else { + setButtonLabel(nextButton, config.next?.label || 'Next'); + nextButton.removeAttribute('data-step-target'); + nextButton.disabled = true; + } + + indicatorNodes.forEach((node, index) => { + const isVisible = index < cardSteps.length; + node.classList.toggle('is-hidden', !isVisible); + if (!isVisible) { + node.classList.remove('is-active'); + return; + } + node.classList.toggle('is-active', cardSteps[index] === step); + }); + + installerCard.setAttribute('data-step', String(step)); + document.body.dataset.step = String(step); + if (locked) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const measureStepHeight = (panel) => { + if (!panel) return; + const height = panel.getBoundingClientRect().height; + if (!height) return; + maxStepHeight = Math.max(maxStepHeight, height); + stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`); + }; + + const runStepInit = (step, rootElement) => { + if (!window.InstallerSteps || typeof window.InstallerSteps.initStep !== 'function') return; + const root = rootElement || stepContainer; + window.InstallerSteps.initStep(step, root); + updateActionBar(step); + }; + + const fetchStepHtml = (step, url) => { + if (stepCache.has(step)) { + return Promise.resolve(stepCache.get(step)); + } + + const fetchUrl = new URL(url); + fetchUrl.searchParams.set('partial', '1'); + + return fetch(fetchUrl.toString(), { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to load step'); + } + return response.text(); + }) + .then((html) => { + stepCache.set(step, html); + return html; + }); + }; + + const preloadSteps = (steps) => { + const current = getStepFromUrl(); + const targets = steps.filter((step) => step !== current); + + return Promise.all( + targets.map((step) => { + const url = buildStepUrl(step); + return fetchStepHtml(step, url) + .then((html) => { + const panel = document.createElement('div'); + panel.className = 'step-panel is-measure'; + panel.innerHTML = html; + stepContainer.appendChild(panel); + panel.getBoundingClientRect(); + measureStepHeight(panel); + panel.remove(); + }) + .catch(() => null); + }) + ); + }; + + const swapPanels = (step, html, onDone) => { + const activePanel = stepContainer.querySelector('.step-panel'); + + const measurePanel = document.createElement('div'); + measurePanel.className = 'step-panel is-measure'; + measurePanel.innerHTML = html; + stepContainer.appendChild(measurePanel); + measurePanel.getBoundingClientRect(); + measureStepHeight(measurePanel); + measurePanel.remove(); + + const newPanel = document.createElement('div'); + newPanel.className = 'step-panel is-entering'; + newPanel.innerHTML = html; + stepContainer.appendChild(newPanel); + runStepInit(step, newPanel); + + newPanel.getBoundingClientRect(); + + requestAnimationFrame(() => { + newPanel.classList.remove('is-entering'); + newPanel.classList.add('is-active'); + if (activePanel) { + activePanel.classList.add('is-exiting'); + } + }); + + const finalize = () => { + if (activePanel && activePanel.parentNode) { + activePanel.parentNode.removeChild(activePanel); + } + newPanel.classList.remove('is-entering'); + if (typeof onDone === 'function') { + onDone(); + } + }; + + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReduced) { + finalize(); + return; + } + + let finished = false; + const finishOnce = () => { + if (finished) return; + finished = true; + finalize(); + }; + + newPanel.addEventListener( + 'transitionend', + (event) => { + if (event.propertyName === 'opacity') { + finishOnce(); + } + }, + { once: true } + ); + + setTimeout(finishOnce, STEP_TRANSITION_TIMEOUT); + }; + + const showInstallScreen = (step, html) => { + if (!installScreen) return; + installScreen.innerHTML = html; + runStepInit(step, installScreen); + }; + + const hideInstallScreen = () => { + if (!installScreen) return; + installScreen.innerHTML = ''; + }; + + const loadStep = (step, pushState) => { + const targetStep = normalizeStep(Number(step)); + const currentStep = getStepFromUrl(); + if (targetStep === currentStep && pushState) return; + + isTransitioning = true; + const url = setStepInUrl(targetStep, pushState); + + fetchStepHtml(targetStep, url) + .then((html) => { + if (targetStep === 4) { + showInstallScreen(targetStep, html); + isTransitioning = false; + if (pendingStep !== null && pendingStep !== targetStep) { + const nextStep = pendingStep; + const nextPushState = pendingPushState; + pendingStep = null; + pendingPushState = false; + loadStep(nextStep, nextPushState); + return; + } + pendingStep = null; + pendingPushState = false; + return; + } + + hideInstallScreen(); + swapPanels(targetStep, html, () => { + isTransitioning = false; + if (pendingStep !== null && pendingStep !== targetStep) { + const nextStep = pendingStep; + const nextPushState = pendingPushState; + pendingStep = null; + pendingPushState = false; + loadStep(nextStep, nextPushState); + return; + } + pendingStep = null; + pendingPushState = false; + }); + }) + .catch(() => { + isTransitioning = false; + window.location.href = url.toString(); + }); + }; + + const requestStep = (step, pushState) => { + const targetStep = normalizeStep(Number(step)); + if (isInstallLocked() && targetStep !== 4) { + loadStep(4, true); + return; + } + if (isTransitioning) { + pendingStep = targetStep; + pendingPushState = pendingPushState || pushState; + return; + } + loadStep(targetStep, pushState); + }; + + document.addEventListener('click', (event) => { + const button = event.target.closest('[data-step-target]'); + if (!button || button.disabled) return; + event.preventDefault(); + const target = button.getAttribute('data-step-target'); + if (!target) return; + const action = button.getAttribute('data-action'); + if (action === 'next') { + const currentStep = getStepFromUrl(); + const panel = stepContainer.querySelector('.step-panel') || stepContainer; + const validator = window.InstallerSteps?.validateStep; + if (typeof validator === 'function') { + const valid = validator(currentStep, panel); + if (!valid) { + scrollToFirstError(panel); + return; + } + } + } + if (isInstallLocked() && Number(target) !== 4) { + requestStep(4, true); + return; + } + requestStep(target, true); + }); + + window.addEventListener('popstate', (event) => { + const step = event.state?.step || getStepFromUrl(); + if (isInstallLocked() && Number(step) !== 4) { + requestStep(4, false); + return; + } + requestStep(step, false); + }); + + document.addEventListener('DOMContentLoaded', () => { + let step = getStepFromUrl(); + if (isInstallLocked() && step !== 4) { + const url = buildStepUrl(4); + window.history.replaceState({ step: 4 }, '', url.toString()); + step = 4; + } else { + const url = buildStepUrl(step); + window.history.replaceState({ step }, '', url.toString()); + } + const activePanel = stepContainer.querySelector('.step-panel') || stepContainer; + runStepInit(step, activePanel); + measureStepHeight(activePanel); + if (step === 4 && installScreen) { + runStepInit(step, installScreen); + } + const preload = () => { + measureStepHeight(activePanel); + preloadSteps(cardSteps); + }; + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(preload).catch(preload); + } else { + preload(); + } + }); +})(); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js new file mode 100644 index 0000000000..dff4b3e831 --- /dev/null +++ b/app/views/install/installer/js/steps.js @@ -0,0 +1,1635 @@ +(() => { + const INSTALL_LOCK_KEY = 'appwrite-install-lock'; + const isMockMode = () => document.body?.dataset.installMode === 'mock' + || (typeof navigator !== 'undefined' && navigator.webdriver); + const getBodyDataset = () => document.body?.dataset ?? {}; + const isUpgradeMode = () => getBodyDataset().upgrade === 'true'; + const getLockedDatabase = () => getBodyDataset().lockedDatabase || ''; + const isMockErrorMode = () => { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + return params.get('mock-error') === '1'; + }; + const isMockProgressMode = () => isMockMode() || isMockErrorMode(); + + const buildInstallationSteps = (upgrade) => (upgrade ? [ + { + id: 'config-files', + inProgress: 'Updating configuration files...', + done: 'Configuration files updated' + }, + { + id: 'docker-compose', + inProgress: 'Updating Docker Compose file...', + done: 'Docker Compose file updated' + }, + { + id: 'env-vars', + inProgress: 'Updating environment variables...', + done: 'Environment variables updated' + }, + { + id: 'docker-containers', + inProgress: 'Restarting Docker containers...', + done: 'Docker containers restarted' + } + ] : [ + { + id: 'config-files', + inProgress: 'Creating configuration files...', + done: 'Configuration files created' + }, + { + id: 'docker-compose', + inProgress: 'Generating Docker Compose file...', + done: 'Docker Compose file generated' + }, + { + id: 'env-vars', + inProgress: 'Configuring environment variables...', + done: 'Environment variables configured' + }, + { + id: 'docker-containers', + inProgress: 'Starting Docker containers...', + done: 'Docker containers started' + } + ]); + + const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode()); + const CONSTANTS = window.InstallerConstants || {}; + const TIMINGS = { + errorClear: CONSTANTS.errorClearMs ?? 180, + installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000, + installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, + mockStepDelay: CONSTANTS.mockStepDelayMs ?? 1800, + progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140 + }; + + const formState = { + appDomain: null, + database: null, + httpPort: null, + httpsPort: null, + emailCertificates: null, + opensslKey: null, + assistantOpenAIKey: null + }; + + let activeInstall = null; + let unloadGuard = null; + let reviewListener = null; + + const dispatchStateChange = (key) => { + if (!key || typeof document === 'undefined') return; + try { + document.dispatchEvent(new CustomEvent('installer:state-change', { + detail: { key, value: formState[key] } + })); + } catch (error) {} + }; + + const clampStep = (step) => { + const numeric = Number(step); + if (Number.isNaN(numeric)) return 1; + return Math.max(1, Math.min(4, numeric)); + }; + + const getInstallLock = () => { + try { + const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch (error) { + return null; + } + }; + + const setInstallLock = (installId, payload) => { + const lock = { + installId, + payload: payload || null, + startedAt: Date.now() + }; + try { + sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); + } catch (error) {} + if (document.body) { + document.body.dataset.installLocked = 'true'; + } + return lock; + }; + + const clearInstallLock = () => { + try { + sessionStorage.removeItem(INSTALL_LOCK_KEY); + } catch (error) {} + if (document.body) { + delete document.body.dataset.installLocked; + } + }; + + const isInstallLocked = () => { + if (isMockProgressMode()) return false; + return Boolean(getInstallLock()); + }; + + const syncInstallLockFlag = () => { + if (!document.body) return; + if (isInstallLocked()) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id); + + const isValidEmail = (email) => { + if (!email) return false; + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); + }; + + const isValidPort = (value) => { + const numeric = Number(value); + if (!Number.isInteger(numeric)) return false; + return numeric >= 1 && numeric <= 65535; + }; + + const isValidIPv4 = (host) => { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false; + return host.split('.').every((part) => { + const num = Number(part); + return num >= 0 && num <= 255; + }); + }; + + const isValidIPv6 = (host) => { + try { + const url = new URL(`http://[${host}]`); + return url.hostname.toLowerCase() === host.toLowerCase(); + } catch (error) { + return false; + } + }; + + const isValidHostnameLabel = (label) => { + if (!label || label.length > 63) return false; + if (label.startsWith('-') || label.endsWith('-')) return false; + return /^[a-zA-Z0-9-]+$/.test(label); + }; + + const isValidDomain = (host) => { + if (host.length > 253) return false; + const labels = host.split('.'); + return labels.every((label) => isValidHostnameLabel(label)); + }; + + const isValidHost = (host) => { + if (host === 'localhost') return true; + if (isValidIPv4(host)) return true; + if (isValidIPv6(host)) return true; + return isValidDomain(host); + }; + + const isValidHostnameInput = (value) => { + if (!value) return false; + const trimmed = value.trim(); + if (!trimmed) return false; + + let host = trimmed; + let port = null; + + if (trimmed.startsWith('[')) { + const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (!match) return false; + host = match[1] || ''; + port = match[2] || null; + } else { + const parts = trimmed.split(':'); + if (parts.length > 2) return false; + if (parts.length === 2) { + host = parts[0]; + port = parts[1]; + } + } + + if (port !== null && port !== '' && !isValidPort(port)) { + return false; + } + + return isValidHost(host); + }; + + const clearFieldErrors = (root) => { + if (!root) return; + root.querySelectorAll('.field-error').forEach((node) => node.remove()); + root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => { + node.classList.remove('is-error'); + }); + }; + + const setFieldError = (input, message) => { + if (!input) return; + const group = input.closest('.input-group'); + if (!group) return; + let error = group.querySelector('.field-error'); + let errorText = error?.querySelector('.field-error-text'); + const hasSameMessage = Boolean(errorText && errorText.textContent === message); + const alreadyVisible = Boolean(error && error.classList.contains('is-visible')); + if (!error) { + const template = document.getElementById('field-error-template'); + if (template && template.content) { + const fragment = template.content.cloneNode(true); + error = fragment.querySelector('.field-error'); + group.appendChild(fragment); + } else { + error = document.createElement('div'); + error.className = 'field-error typography-text-xs-400 text-error'; + const text = document.createElement('span'); + text.className = 'field-error-text'; + error.appendChild(text); + group.appendChild(error); + } + errorText = error.querySelector('.field-error-text'); + } + if (errorText) { + errorText.textContent = message; + } + if (!hasSameMessage || !alreadyVisible) { + requestAnimationFrame(() => { + error.classList.add('is-visible'); + }); + } + input.classList.add('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.add('is-error'); + } + }; + + const bindErrorClear = (input) => { + if (!input) return; + const handler = () => { + const group = input.closest('.input-group'); + const error = group?.querySelector('.field-error'); + if (error) { + error.classList.remove('is-visible'); + setTimeout(() => { + error.remove(); + }, TIMINGS.errorClear); + } + input.classList.remove('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.remove('is-error'); + } + }; + input.addEventListener('input', handler); + input.addEventListener('change', handler); + }; + + const setStateIfEmpty = (key, value) => { + if (value === null || value === undefined || value === '') return; + if (formState[key] === null || formState[key] === undefined || formState[key] === '') { + formState[key] = value; + } + }; + + const applyBodyDefaults = () => { + const data = getBodyDataset(); + setStateIfEmpty('appDomain', data.defaultAppDomain); + setStateIfEmpty('httpPort', data.defaultHttpPort); + setStateIfEmpty('httpsPort', data.defaultHttpsPort); + setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); + setStateIfEmpty('opensslKey', data.defaultSecretKey); + setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); + if (data.lockedDatabase) { + formState.database = data.lockedDatabase; + } + setStateIfEmpty('database', data.defaultDatabase); + }; + + const toDatabaseLabel = (value) => { + if (!value) return ''; + return value.toLowerCase() === 'mariadb' ? 'MariaDB' : 'MongoDB'; + }; + + const updateDatabaseSelection = (radio, root) => { + if (!radio || !root) return; + const allOptions = root.querySelectorAll('.selector-card'); + allOptions.forEach((option) => option.classList.remove('selected')); + const selectedOption = radio.closest('.selector-card'); + if (selectedOption) { + selectedOption.classList.add('selected'); + } + }; + + const syncResetButton = (input, button) => { + const defaultValue = input.dataset.default ?? ''; + button.disabled = input.value === defaultValue; + }; + + const setupResetButtons = (root) => { + const inputs = root.querySelectorAll('.input-field[data-default]'); + inputs.forEach((input) => { + const button = root.querySelector(`[data-reset-target="${input.id}"]`); + if (!button) return; + + syncResetButton(input, button); + + input.addEventListener('input', () => syncResetButton(input, button)); + button.addEventListener('click', () => { + input.value = input.dataset.default ?? ''; + syncResetButton(input, button); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }); + }; + + const toggleAccordion = (button) => { + const content = button.nextElementSibling; + const icon = button.querySelector('.accordion-chevron'); + const isOpen = button.classList.contains('is-open'); + + button.classList.toggle('is-open', !isOpen); + button.setAttribute('aria-expanded', String(!isOpen)); + + if (content) { + if (!isOpen) { + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } else { + content.style.maxHeight = '0px'; + content.classList.remove('open'); + } + } + + if (icon) { + icon.setAttribute('data-open', String(!isOpen)); + } + }; + + const setupAccordion = (root) => { + const buttons = root.querySelectorAll('.accordion-toggle'); + buttons.forEach((button) => { + button.addEventListener('click', () => toggleAccordion(button)); + }); + }; + + const setUnloadGuard = (enabled) => { + if (!enabled && unloadGuard) { + window.removeEventListener('beforeunload', unloadGuard); + unloadGuard = null; + return; + } + + if (enabled && !unloadGuard) { + unloadGuard = (event) => { + event.preventDefault(); + event.returnValue = ''; + return ''; + }; + window.addEventListener('beforeunload', unloadGuard); + } + }; + + const bindInputToState = (input, key) => { + if (!input) return; + const update = () => { + formState[key] = input.value; + dispatchStateChange(key); + }; + input.addEventListener('input', update); + input.addEventListener('change', update); + update(); + }; + + const bindDatabaseSelection = (root) => { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + radio.addEventListener('change', () => { + formState.database = radio.value; + updateDatabaseSelection(radio, root); + }); + }); + }; + + 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 hydrateStep1State = (root) => { + setStateIfEmpty('appDomain', root.querySelector('#hostname')?.value); + setStateIfEmpty('database', root.querySelector('input[name="database"]:checked')?.value); + setStateIfEmpty('httpPort', root.querySelector('#http-port')?.value); + setStateIfEmpty('httpsPort', root.querySelector('#https-port')?.value); + setStateIfEmpty('emailCertificates', root.querySelector('#ssl-email')?.value); + setStateIfEmpty('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); + }; + + const applyLockPayload = () => { + const lock = getInstallLock(); + if (!lock || !lock.payload) return; + const payload = lock.payload; + setStateIfEmpty('appDomain', payload.appDomain); + setStateIfEmpty('database', payload.database); + setStateIfEmpty('httpPort', payload.httpPort); + setStateIfEmpty('httpsPort', payload.httpsPort); + setStateIfEmpty('emailCertificates', payload.emailCertificates); + setStateIfEmpty('opensslKey', payload.opensslKey); + setStateIfEmpty('assistantOpenAIKey', payload.assistantOpenAIKey); + }; + + const applyStep1State = (root) => { + const hostname = root.querySelector('#hostname'); + if (hostname && formState.appDomain) hostname.value = formState.appDomain; + + const httpPort = root.querySelector('#http-port'); + if (httpPort && formState.httpPort) httpPort.value = formState.httpPort; + + const httpsPort = root.querySelector('#https-port'); + if (httpsPort && formState.httpsPort) httpsPort.value = formState.httpsPort; + + const sslEmail = root.querySelector('#ssl-email'); + if (sslEmail && formState.emailCertificates) sslEmail.value = formState.emailCertificates; + + const assistantKey = root.querySelector('#assistant-openai-key'); + if (assistantKey && formState.assistantOpenAIKey) { + assistantKey.value = formState.assistantOpenAIKey; + } + + if (formState.database) { + const radio = root.querySelector(`input[name="database"][value="${formState.database}"]`); + if (radio) { + radio.checked = true; + updateDatabaseSelection(radio, root); + } + } + }; + + const openAccordion = (root) => { + const toggle = root.querySelector('.accordion-toggle'); + const content = root.querySelector('.accordion-content'); + if (!toggle || !content) return; + if (!toggle.classList.contains('is-open')) { + toggle.classList.add('is-open'); + toggle.setAttribute('aria-expanded', 'true'); + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } + }; + + const disableControls = (root) => { + const inputs = root.querySelectorAll('input, select, textarea'); + inputs.forEach((input) => { + if (input.type === 'radio' || input.type === 'checkbox') { + input.disabled = true; + } else { + input.readOnly = true; + input.setAttribute('aria-disabled', 'true'); + } + }); + + const buttons = root.querySelectorAll('button'); + buttons.forEach((button) => { + if (button.matches('[data-copy-target]')) return; + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + }); + + root.classList.add('is-locked'); + }; + + const initStep1 = (root) => { + if (!root) return; + if (isMockMode()) { + clearInstallLock(); + clearInstallId(); + } + syncInstallLockFlag(); + applyLockPayload(); + applyBodyDefaults(); + hydrateStep1State(root); + applyStep1State(root); + + if (isInstallLocked()) { + openAccordion(root); + disableControls(root); + return; + } + + const lockedDatabase = getLockedDatabase(); + if (lockedDatabase) { + lockDatabaseSelection(root, lockedDatabase); + } else { + bindDatabaseSelection(root); + } + + const hostname = root.querySelector('#hostname'); + const httpPort = root.querySelector('#http-port'); + const httpsPort = root.querySelector('#https-port'); + const sslEmail = root.querySelector('#ssl-email'); + const assistantKey = root.querySelector('#assistant-openai-key'); + + bindInputToState(hostname, 'appDomain'); + bindInputToState(httpPort, 'httpPort'); + bindInputToState(httpsPort, 'httpsPort'); + bindInputToState(sslEmail, 'emailCertificates'); + bindInputToState(assistantKey, 'assistantOpenAIKey'); + + bindErrorClear(hostname); + bindErrorClear(httpPort); + bindErrorClear(httpsPort); + bindErrorClear(sslEmail); + bindErrorClear(assistantKey); + + const checked = root.querySelector('input[name="database"]:checked'); + if (checked) { + updateDatabaseSelection(checked, root); + } + + setupResetButtons(root); + setupAccordion(root); + }; + + const generateSecretKey = () => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const copyToClipboard = (value, input) => { + if (!value) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value); + return; + } + if (input) { + input.select(); + document.execCommand('copy'); + input.setSelectionRange(0, 0); + return; + } + const textArea = document.createElement('textarea'); + textArea.value = value; + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + } catch (error) {} finally { + document.body.removeChild(textArea); + } + }; + + const setTooltipText = (wrapper, message) => { + if (!wrapper) return; + const tooltip = wrapper.querySelector('.tooltip'); + if (tooltip && message) { + tooltip.textContent = message; + } + }; + + const resetTooltipText = (wrapper) => { + if (!wrapper) return; + const defaultText = wrapper.dataset.tooltipDefault; + if (!defaultText) return; + setTooltipText(wrapper, defaultText); + }; + + const hydrateStep2State = (root) => { + const value = root.querySelector('#secret-key')?.value; + if (formState.opensslKey) return; + if (value) { + formState.opensslKey = value; + } + }; + + const applyStep2State = (root) => { + const input = root.querySelector('#secret-key'); + if (input && formState.opensslKey) { + input.value = formState.opensslKey; + } + }; + + const initStep2 = (root) => { + if (!root) return; + if (isMockMode()) { + clearInstallLock(); + clearInstallId(); + } + syncInstallLockFlag(); + applyLockPayload(); + applyBodyDefaults(); + hydrateStep2State(root); + if (!formState.opensslKey || !formState.opensslKey.trim()) { + formState.opensslKey = generateSecretKey(); + dispatchStateChange('opensslKey'); + } + applyStep2State(root); + + const input = root.querySelector('#secret-key'); + if (input) { + bindInputToState(input, 'opensslKey'); + bindErrorClear(input); + } + + const copyButton = root.querySelector('[data-copy-target]'); + const tooltipWrapper = copyButton?.closest('.tooltip-wrapper'); + + if (tooltipWrapper) { + tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText(tooltipWrapper)); + tooltipWrapper.addEventListener('focusin', () => resetTooltipText(tooltipWrapper)); + } + + if (copyButton) { + copyButton.addEventListener('click', () => { + const targetId = copyButton.getAttribute('data-copy-target'); + const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; + const value = targetInput?.value || ''; + copyToClipboard(value, targetInput); + copyButton.blur(); + + if (tooltipWrapper) { + const successText = tooltipWrapper.dataset.tooltipSuccess || 'Copied'; + setTooltipText(tooltipWrapper, successText); + } + }); + } + + const regenerateButton = root.querySelector('[data-regenerate-target]'); + if (regenerateButton && !isInstallLocked()) { + regenerateButton.addEventListener('click', () => { + const targetId = regenerateButton.getAttribute('data-regenerate-target'); + const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; + if (!targetInput) return; + regenerateButton.classList.remove('is-rotating'); + void regenerateButton.offsetWidth; + regenerateButton.classList.add('is-rotating'); + const handleAnimationEnd = () => { + regenerateButton.classList.remove('is-rotating'); + }; + regenerateButton.addEventListener('animationend', handleAnimationEnd, { once: true }); + targetInput.value = generateSecretKey(); + targetInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + if (isInstallLocked()) { + disableControls(root); + } + }; + + const updateReviewSummary = (root) => { + if (!root) return; + const valueNodes = root.querySelectorAll('[data-review-value]'); + valueNodes.forEach((node) => { + const key = node.dataset.reviewValue; + if (!key) return; + let value = formState[key]; + if (key === 'database') { + value = toDatabaseLabel(formState.database); + } + if (value) { + node.textContent = value; + } + }); + + const badge = root.querySelector('[data-review-badge]'); + if (badge) { + const hasKey = Boolean((formState.opensslKey || '').trim()); + badge.textContent = hasKey ? 'Generated' : 'Missing'; + badge.classList.remove('badge-success', 'badge-warning'); + badge.classList.add(hasKey ? 'badge-success' : 'badge-warning'); + } + + const assistantBadge = root.querySelector('[data-review-assistant-badge]'); + if (assistantBadge) { + const hasAssistantKey = Boolean((formState.assistantOpenAIKey || '').trim()); + assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled'; + assistantBadge.classList.remove('badge-success', 'badge-neutral'); + assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral'); + } + }; + + const initStep3 = (root) => { + if (!root) return; + if (isMockMode()) { + clearInstallLock(); + clearInstallId(); + } + syncInstallLockFlag(); + applyLockPayload(); + applyBodyDefaults(); + updateReviewSummary(root); + if (reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + } + reviewListener = () => updateReviewSummary(root); + document.addEventListener('installer:state-change', reviewListener); + if (isInstallLocked()) { + disableControls(root); + } + }; + + const getProgressLabel = (step, status, message) => { + if (!step) return message || ''; + if (status === 'error') { + const normalized = normalizeInstallError(message || ''); + return normalized.summary || 'Installation failed.'; + } + if (status === 'completed') return step.done; + return step.inProgress; + }; + + const updateInstallRow = (row, step, status, message) => { + if (!row || !step) return; + row.dataset.status = status; + row.dataset.step = step.id; + if (status !== 'error') { + row.classList.remove('is-open'); + const toggle = row.querySelector('[data-install-toggle]'); + if (toggle) { + toggle.setAttribute('aria-expanded', 'false'); + } + } + const label = getProgressLabel(step, status, message); + const text = row.querySelector('[data-install-text]'); + if (text) { + text.textContent = label; + } + }; + + const normalizeInstallError = (message) => { + const text = String(message || '').trim(); + if (!text) { + return { summary: '', details: '' }; + } + const colonIndex = text.indexOf(':'); + if (colonIndex > 0 && colonIndex < 80) { + const summary = text.slice(0, colonIndex).trim(); + const details = text.slice(colonIndex + 1).trim(); + return { summary, details }; + } + if (text.length > 180) { + return { summary: text.slice(0, 180).trim() + '…', details: text }; + } + return { summary: text, details: '' }; + }; + + const updateInstallErrorDetails = (row, error) => { + if (!row) return; + const traceNode = row.querySelector('[data-install-trace]'); + const normalized = normalizeInstallError(error?.message || ''); + const output = error?.output || ''; + const trace = error?.trace || ''; + const detailChunks = []; + if (normalized.details) detailChunks.push(normalized.details); + if (output) detailChunks.push(output); + if (trace) detailChunks.push(trace); + const detailText = detailChunks.join('\n\n'); + + if (traceNode) { + traceNode.textContent = detailText; + traceNode.style.display = detailText ? 'block' : 'none'; + } + }; + + const createInstallRow = (template, step) => { + const fragment = template.content.cloneNode(true); + const row = fragment.querySelector('.install-row'); + if (!row) return null; + const toggle = row.querySelector('[data-install-toggle]'); + const copyButton = row.querySelector('[data-install-copy]'); + const copyWrapper = copyButton?.closest('.tooltip-wrapper'); + const setOpenState = (isOpen) => { + row.classList.toggle('is-open', isOpen); + if (toggle) { + toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + } + }; + const toggleRow = () => { + if (!row.dataset.status || row.dataset.status !== 'error') { + return; + } + setOpenState(!row.classList.contains('is-open')); + }; + row.addEventListener('click', (event) => { + if (event.target.closest('[data-install-retry]')) { + return; + } + if (event.target.closest('[data-install-toggle]')) { + return; + } + if (event.target.closest('.install-row-details')) { + return; + } + toggleRow(); + }); + if (toggle) { + toggle.addEventListener('click', (event) => { + event.stopPropagation(); + toggleRow(); + }); + } + if (copyWrapper) { + copyWrapper.addEventListener('mouseenter', () => resetTooltipText(copyWrapper)); + copyWrapper.addEventListener('focusin', () => resetTooltipText(copyWrapper)); + } + if (copyButton) { + copyButton.addEventListener('click', (event) => { + event.stopPropagation(); + const traceNode = row.querySelector('[data-install-trace]'); + const text = traceNode?.textContent?.trim() || ''; + copyToClipboard(text); + if (copyWrapper) { + const successText = copyWrapper.dataset.tooltipSuccess || 'Copied'; + setTooltipText(copyWrapper, successText); + } + }); + } + updateInstallRow(row, step, 'in-progress'); + return row; + }; + + const generateInstallId = () => { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const extractHostname = (value) => { + if (!value) return ''; + const trimmed = value.trim(); + if (trimmed.startsWith('[')) { + const end = trimmed.indexOf(']'); + if (end !== -1) { + return trimmed.slice(1, end); + } + return trimmed; + } + const colonCount = (trimmed.match(/:/g) || []).length; + if (colonCount === 1) { + return trimmed.split(':')[0]; + } + return trimmed; + }; + + const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); + + const isLocalHost = (host) => { + if (!host) return false; + const normalized = host.toLowerCase(); + return LOCAL_HOSTS.has(normalized); + }; + + const buildRedirectUrl = () => { + const dataset = getBodyDataset(); + const rawDomain = (formState.appDomain || dataset.defaultAppDomain || '').trim(); + if (!rawDomain) return ''; + const httpPort = (formState.httpPort || dataset.defaultHttpPort || '').trim(); + const httpsPort = (formState.httpsPort || dataset.defaultHttpsPort || '').trim(); + const hasPort = rawDomain.includes(':') || rawDomain.startsWith('['); + let host = rawDomain; + const hostForProtocol = extractHostname(rawDomain); + if (hostForProtocol === '0.0.0.0') { + host = rawDomain.replace('0.0.0.0', 'localhost'); + } + let protocol = 'http'; + let port = httpPort; + if (httpsPort && httpsPort !== '0' && !isLocalHost(hostForProtocol)) { + protocol = 'https'; + port = httpsPort; + } + if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) { + host = `${rawDomain}:${port}`; + } + return `${protocol}://${host}`; + }; + + const redirectToApp = () => { + if (isMockMode()) return; + const url = buildRedirectUrl(); + if (!url) return; + window.location.href = url; + }; + + const getStoredInstallId = () => { + try { + return sessionStorage.getItem('appwrite-install-id'); + } catch (error) { + return null; + } + }; + + const storeInstallId = (installId) => { + try { + sessionStorage.setItem('appwrite-install-id', installId); + } catch (error) {} + }; + + const clearInstallId = () => { + try { + sessionStorage.removeItem('appwrite-install-id'); + } catch (error) {} + }; + + const buildInstallPayload = (installId) => { + const normalizedSecret = (formState.opensslKey || '').trim(); + if (!normalizedSecret) { + formState.opensslKey = generateSecretKey(); + } + const normalizedDomain = (formState.appDomain || '').trim() || 'localhost'; + const normalizedHttpPort = (formState.httpPort || '').trim() || '80'; + const normalizedHttpsPort = (formState.httpsPort || '').trim() || '443'; + const normalizedEmail = (formState.emailCertificates || '').trim(); + const normalizedAssistantKey = (formState.assistantOpenAIKey || '').trim(); + + return { + installId, + httpPort: normalizedHttpPort, + httpsPort: normalizedHttpsPort, + database: formState.database || 'mongodb', + appDomain: normalizedDomain, + emailCertificates: normalizedEmail, + opensslKey: (formState.opensslKey || '').trim(), + assistantOpenAIKey: normalizedAssistantKey + }; + }; + + const fetchInstallStatus = async (installId) => { + if (isMockProgressMode()) return null; + if (!installId) return null; + const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, { + cache: 'no-store' + }); + if (!response.ok) return null; + const json = await response.json(); + return json.progress || null; + }; + + const readEventStream = async (stream, onEvent) => { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let separatorIndex = buffer.indexOf('\n\n'); + + while (separatorIndex !== -1) { + const rawEvent = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + const lines = rawEvent.split('\n'); + let eventName = 'message'; + let data = ''; + + lines.forEach((line) => { + if (line.startsWith('event:')) { + eventName = line.replace('event:', '').trim(); + } else if (line.startsWith('data:')) { + data += line.replace('data:', '').trim(); + } + }); + + if (data) { + try { + onEvent(eventName, JSON.parse(data)); + } catch (error) { + onEvent(eventName, { message: data }); + } + } + + separatorIndex = buffer.indexOf('\n\n'); + } + } + } finally { + try { + reader.releaseLock(); + } catch (error) {} + } + }; + + const initStep4 = (root) => { + if (!root) return; + if (isMockProgressMode()) { + clearInstallLock(); + clearInstallId(); + } + + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + + const list = root.querySelector('[data-install-list]'); + const template = root.querySelector('#install-row-template'); + if (!list || !template) return; + + list.innerHTML = ''; + const rowsById = new Map(); + const progressState = new Map(); + let pendingProgressTimer = null; + let pendingProgressStep = null; + syncInstallLockFlag(); + applyLockPayload(); + applyBodyDefaults(); + + const ensureRow = (step) => { + if (!step) return null; + if (rowsById.has(step.id)) { + return rowsById.get(step.id); + } + const row = createInstallRow(template, step); + if (!row) return null; + row.classList.add('is-entering'); + list.appendChild(row); + row.getBoundingClientRect(); + requestAnimationFrame(() => { + row.classList.remove('is-entering'); + }); + rowsById.set(step.id, row); + return row; + }; + + const installPanel = root.querySelector('.install-panel'); + let panelHeightCleanup = null; + const animatePanelHeight = (mutate) => { + if (!installPanel) { + mutate(); + return; + } + if (panelHeightCleanup) { + panelHeightCleanup(); + panelHeightCleanup = null; + } + const currentHeight = installPanel.getBoundingClientRect().height; + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + mutate(); + const nextHeight = installPanel.getBoundingClientRect().height; + if (currentHeight === nextHeight) { + installPanel.style.height = ''; + return; + } + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + installPanel.style.height = `${nextHeight}px`; + const cleanup = () => { + installPanel.style.height = ''; + installPanel.removeEventListener('transitionend', onEnd); + }; + const onEnd = (event) => { + if (event.propertyName === 'height') { + cleanup(); + } + }; + panelHeightCleanup = cleanup; + installPanel.addEventListener('transitionend', onEnd); + }; + + const renderProgress = () => { + animatePanelHeight(() => { + const visibleSteps = []; + for (const step of INSTALLATION_STEPS) { + const state = progressState.get(step.id); + if (!state) break; + visibleSteps.push(step); + } + + visibleSteps.forEach((step) => { + const state = progressState.get(step.id); + if (!state) return; + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, state.status || 'in-progress', state.message); + if (state.status === 'error') { + updateInstallErrorDetails(row, { + message: state.message, + trace: state.details?.trace, + output: state.details?.output + }); + } + } + }); + }); + }; + + const firstStep = INSTALLATION_STEPS[0]; + if (firstStep) { + progressState.set(firstStep.id, { + status: 'in-progress', + message: firstStep.inProgress + }); + } + renderProgress(); + + const applyProgress = (payload) => { + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + progressState.set(step.id, { + status: payload.status || 'in-progress', + message: payload.message, + details: payload.details + }); + renderProgress(); + if (activeInstall) { + activeInstall.lastEventAt = Date.now(); + if (payload.status === 'error') { + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + activeInstall.pollTimer = null; + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + activeInstall.fallbackTimer = null; + } + } + } + scheduleFallback(); + }; + + const handleProgress = (payload) => { + if (!payload || !payload.step) return; + if (pendingProgressTimer && pendingProgressStep === payload.step && payload.status !== 'in-progress') { + clearTimeout(pendingProgressTimer); + pendingProgressTimer = null; + pendingProgressStep = null; + } + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + if (payload.status === 'in-progress') { + const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id); + let didAutoComplete = false; + 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, + details: previousState.details + }); + didAutoComplete = true; + } + } + } + if (didAutoComplete && currentIndex > 0) { + renderProgress(); + pendingProgressStep = payload.step; + pendingProgressTimer = setTimeout(() => { + pendingProgressTimer = null; + pendingProgressStep = null; + applyProgress(payload); + }, TIMINGS.progressTransitionDelay); + scheduleFallback(); + return; + } + } + applyProgress(payload); + }; + + const applySnapshot = (snapshot) => { + if (!snapshot || !snapshot.steps) return; + INSTALLATION_STEPS.forEach((step) => { + const detail = snapshot.steps[step.id]; + if (!detail) return; + progressState.set(step.id, { + status: detail.status, + message: detail.message, + details: snapshot.details?.[step.id] + }); + }); + renderProgress(); + }; + + const startPolling = () => { + if (isMockProgressMode()) return; + if (!activeInstall || activeInstall.pollTimer) return; + activeInstall.pollTimer = setInterval(async () => { + if (!activeInstall || activeInstall.completed) return; + const snapshot = await fetchInstallStatus(activeInstall.installId); + if (snapshot) { + applySnapshot(snapshot); + } + }, TIMINGS.installPollInterval); + }; + + const scheduleFallback = () => { + if (isMockProgressMode()) return; + if (!activeInstall) return; + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall.fallbackTimer = setTimeout(() => { + if (!activeInstall) return; + startPolling(); + }, TIMINGS.installFallbackDelay); + }; + + const finalizeInstall = () => { + if (!activeInstall) return; + activeInstall.completed = true; + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + setUnloadGuard(false); + }; + + const startInstallStream = async (installId, options = {}) => { + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + + const payload = buildInstallPayload(installId); + if (options.retryStep) { + payload.retryStep = options.retryStep; + } + if (!isMockProgressMode()) { + setInstallLock(installId, payload); + setUnloadGuard(true); + } + + try { + if (options.forceMock || isMockProgressMode()) { + simulateInstallProgress(options); + return; + } + scheduleFallback(); + const response = await fetch('/install', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify(payload), + signal: activeInstall.controller.signal + }); + + if (!response.ok || !response.body) { + let errorMessage = null; + try { + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + const data = await response.json(); + errorMessage = data?.message || null; + } + } catch (error) { + errorMessage = null; + } + if (errorMessage) { + handleProgress({ + step: 'config-files', + status: 'error', + message: errorMessage + }); + finalizeInstall(); + return; + } + startPolling(); + return; + } + + await readEventStream(response.body, (event, data) => { + if (!activeInstall) return; + if (event === 'install-id' && data?.installId) { + activeInstall.installId = data.installId; + storeInstallId(data.installId); + return; + } + if (event === 'progress') { + handleProgress(data); + return; + } + if (event === 'done') { + finalizeInstall(); + redirectToApp(); + return; + } + if (event === 'error') { + if (data?.message) { + const existingError = Array.from(progressState.values()).some((state) => state?.status === 'error'); + if (data.step || !existingError) { + let targetStep = data.step; + if (!targetStep) { + for (const candidate of INSTALLATION_STEPS) { + const state = progressState.get(candidate.id); + if (!state || state.status !== 'completed') { + targetStep = candidate.id; + break; + } + } + } + handleProgress({ + step: targetStep || 'config-files', + status: 'error', + message: data.message, + details: data.details + }); + } + } + finalizeInstall(); + } + }); + if (activeInstall && !activeInstall.completed) { + startPolling(); + } + } catch (error) { + if (!activeInstall || activeInstall.controller.signal.aborted) { + return; + } + startPolling(); + } + }; + + const resumeInstall = async (installId) => { + const snapshot = await fetchInstallStatus(installId); + if (!snapshot) return false; + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + applySnapshot(snapshot); + startPolling(); + setUnloadGuard(true); + return true; + }; + + const resetProgressFrom = (stepId) => { + const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId); + if (index === -1) return; + INSTALLATION_STEPS.slice(index).forEach((step) => { + progressState.delete(step.id); + const row = rowsById.get(step.id); + if (row && row.parentNode) { + row.parentNode.removeChild(row); + } + rowsById.delete(step.id); + }); + }; + + const retryInstallStep = (stepId) => { + if (!stepId) return; + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + resetProgressFrom(stepId); + handleProgress({ + step: stepId, + status: 'in-progress', + message: getStepDefinition(stepId)?.inProgress || 'Retrying...' + }); + const installId = activeInstall?.installId || getInstallLock()?.installId || generateInstallId(); + storeInstallId(installId); + startInstallStream(installId, { retryStep: stepId, forceMock: isMockProgressMode() }); + }; + + list.addEventListener('click', (event) => { + const button = event.target.closest('[data-install-retry]'); + if (!button) return; + const row = button.closest('.install-row'); + const stepId = row?.dataset.step; + retryInstallStep(stepId); + }); + + const simulateInstallProgress = (options = {}) => { + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + const startIndex = options.retryStep + ? Math.max(0, INSTALLATION_STEPS.findIndex((step) => step.id === options.retryStep)) + : 0; + let index = startIndex; + const errorStepId = isMockErrorMode() && !options.retryStep ? 'docker-containers' : null; + const mockErrorDetails = CONSTANTS.mockErrorDetails || { + output: 'Error response from daemon: manifest for appwrite/appwrite:local not found: manifest unknown', + trace: '#0 /usr/src/code/src/Appwrite/Platform/Tasks/Install.php(540): Appwrite\\\\Platform\\\\Tasks\\\\Install->performInstallation(...)\\n#1 {main}' + }; + const advance = () => { + if (index > 0) { + const previous = INSTALLATION_STEPS[index - 1]; + const previousState = progressState.get(previous.id); + if (!previousState || previousState.status !== 'completed') { + handleProgress({ + step: previous.id, + status: 'completed', + message: previous.done + }); + } + } + + if (index >= INSTALLATION_STEPS.length) { + finalizeInstall(); + return; + } + + const step = INSTALLATION_STEPS[index]; + handleProgress({ + step: step.id, + status: 'in-progress', + message: step.inProgress + }); + + if (errorStepId && step.id === errorStepId) { + index += 1; + activeInstall.pollTimer = setTimeout(() => { + handleProgress({ + step: step.id, + status: 'error', + message: 'Failed to start containers', + details: mockErrorDetails + }); + finalizeInstall(); + }, TIMINGS.mockStepDelay); + return; + } + + index += 1; + activeInstall.pollTimer = setTimeout(advance, TIMINGS.mockStepDelay); + }; + advance(); + }; + + if (isMockProgressMode()) { + const newInstallId = generateInstallId(); + storeInstallId(newInstallId); + startInstallStream(newInstallId); + return; + } + + const lock = getInstallLock(); + const existingInstallId = lock?.installId || getStoredInstallId(); + if (existingInstallId) { + resumeInstall(existingInstallId).then((resumed) => { + if (!resumed) { + clearInstallId(); + clearInstallLock(); + const newInstallId = generateInstallId(); + storeInstallId(newInstallId); + startInstallStream(newInstallId); + } + }); + } else { + const newInstallId = generateInstallId(); + storeInstallId(newInstallId); + startInstallStream(newInstallId); + } + }; + + const initStep = (step, container) => { + if (!container) return; + const root = container.querySelector('.step-layout') || container; + const normalized = clampStep(step); + if (normalized !== 3 && reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + reviewListener = null; + } + if (normalized !== 4 && activeInstall?.controller) { + activeInstall.controller.abort(); + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + setUnloadGuard(false); + } + if (normalized === 1) initStep1(root); + if (normalized === 2) initStep2(root); + if (normalized === 3) initStep3(root); + if (normalized === 4) initStep4(root); + }; + + window.InstallerSteps = { + initStep1, + initStep2, + initStep3, + initStep4, + installationSteps: INSTALLATION_STEPS, + isInstallLocked, + getInstallLock, + clearInstallLock, + initStep, + validateStep: (step, container) => { + const root = container?.querySelector('.step-layout') || container; + const normalized = clampStep(step); + if (normalized === 1) { + clearFieldErrors(root); + let valid = true; + const hostname = root?.querySelector('#hostname'); + const httpPort = root?.querySelector('#http-port'); + const httpsPort = root?.querySelector('#https-port'); + const sslEmail = root?.querySelector('#ssl-email'); + + if (!hostname || !hostname.value.trim()) { + setFieldError(hostname, 'Please enter your Appwrite hostname'); + valid = false; + } else if (!isValidHostnameInput(hostname.value.trim())) { + setFieldError(hostname, 'Please enter a valid hostname'); + valid = false; + } + + const parsePort = (input, label) => { + const value = input?.value; + if (!value || !isValidPort(value)) { + setFieldError(input, `Please enter a valid ${label} port (1-65535)`); + return false; + } + return true; + }; + + if (!parsePort(httpPort, 'HTTP')) valid = false; + if (!parsePort(httpsPort, 'HTTPS')) valid = false; + + if (!sslEmail || !sslEmail.value.trim()) { + setFieldError(sslEmail, 'Please enter an email address for SSL certificates'); + valid = false; + } else if (!isValidEmail(sslEmail.value.trim())) { + setFieldError(sslEmail, 'Please enter a valid email address'); + valid = false; + } + + if (!valid) { + openAccordion(root); + } + + return valid; + } + + if (normalized === 2) { + clearFieldErrors(root); + const secretKey = root?.querySelector('#secret-key'); + const secretValue = secretKey?.value.trim() || ''; + if (!secretKey || !secretValue) { + setFieldError(secretKey, 'Please enter or generate a secret API key'); + return false; + } + if (secretValue.length > 64) { + setFieldError(secretKey, 'Secret API key must be 1-64 characters'); + return false; + } + } + + return true; + } + }; +})(); diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml new file mode 100644 index 0000000000..1afdd7ebe9 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -0,0 +1,169 @@ + +
+
+
+

+

+ +

+
+ +
+
+ + +
+ +
+ +
+ + + + + + + +
+
+ +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+
+
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-2.phtml b/app/views/install/installer/templates/steps/step-2.phtml new file mode 100644 index 0000000000..29c58ab58b --- /dev/null +++ b/app/views/install/installer/templates/steps/step-2.phtml @@ -0,0 +1,58 @@ + +
+
+
+

Secure your app

+

+ +

+
+ +
+
+
+ +
+
Save your key
+
You won't be able to see this key again. Copy it somewhere safe before continuing.
+
+
+
+ +
+
+ +
+ +
+ + + Copy + +
+
+
+ +
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-3.phtml b/app/views/install/installer/templates/steps/step-3.phtml new file mode 100644 index 0000000000..1d3a80c593 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-3.phtml @@ -0,0 +1,72 @@ + +
+
+
+

+

+ +

+
+ +
+
+
+
+
+ +
+
Hostname
+
+
+
+ +
+
Database
+
+
+
+ +
+
HTTP port
+
+
+
+ +
+
HTTPS port
+
+
+
+ +
+
SSL certificate email
+
+
+ Disabled +
Appwrite Assistant
+
+
+ + + +
Secret API key
+
+
+
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml new file mode 100644 index 0000000000..b02bc29e1a --- /dev/null +++ b/app/views/install/installer/templates/steps/step-4.phtml @@ -0,0 +1,49 @@ + +
+
+
+
+
+ +
+
+
+
+
+ + +
diff --git a/app/views/install/router.php b/app/views/install/router.php new file mode 100644 index 0000000000..8e97455134 --- /dev/null +++ b/app/views/install/router.php @@ -0,0 +1,257 @@ +/dev/null 2>&1'; + \exec($command); +} + +#[NoReturn] +function startInstallerDevServer(string $host, string $port): void +{ + $binary = escapeshellcmd(PHP_BINARY); + $address = escapeshellarg("$host:$port"); + $docroot = escapeshellarg(__DIR__); + $router = escapeshellarg(__FILE__); + $command = "$binary -S $address -t $docroot $router"; + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + if (!is_resource($process)) { + fwrite(STDERR, "Failed to start PHP dev server." . PHP_EOL); + exit(1); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + while (true) { + $read = [$pipes[1], $pipes[2]]; + $write = null; + $except = null; + + if (stream_select($read, $write, $except, null) === false) { + break; + } + + foreach ($read as $stream) { + $line = fgets($stream); + if ($line === false) { + continue; + } + + if ($stream === $pipes[2] && preg_match(DEV_SERVER_START_PATTERN, $line)) { + continue; + } + + $target = ($stream === $pipes[2]) ? STDERR : STDOUT; + fwrite($target, $line); + } + + $status = proc_get_status($process); + if (!$status['running']) { + break; + } + } + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + $exitCode = proc_close($process); + exit($exitCode); +} + +function removeDockerInstallerContainer(string $container): void +{ + $name = escapeshellarg($container); + \exec("docker rm -f {$name} >/dev/null 2>&1"); +} + +function dockerImageExists(string $image): bool +{ + $name = escapeshellarg($image); + $result = 1; + \exec("docker image inspect $name >/dev/null 2>&1", $output, $result); + return $result === 0; +} + +function buildDockerInstallerImage(string $image): void +{ + fwrite(STDOUT, "Building Docker image: {$image}\n"); + $buildCommand = 'docker compose build appwrite'; + passthru($buildCommand, $status); + if ((int)$status !== 0 || !dockerImageExists($image)) { + fwrite(STDERR, "Failed to build Docker image: {$image}\n"); + fwrite(STDERR, "Try: docker compose build appwrite\n"); + exit(1); + } +} + +function ensureLocalInstallerTag(string $source, string $target): void +{ + $sourceArg = escapeshellarg($source); + $targetArg = escapeshellarg($target); + $exists = 1; + \exec("docker image inspect {$targetArg} >/dev/null 2>&1", $output, $exists); + if ($exists === 0) { + return; + } + \exec("docker tag {$sourceArg} {$targetArg}", $tagOutput, $tagStatus); + if ($tagStatus !== 0) { + fwrite(STDERR, "Failed to tag Docker image {$source} as {$target}\n"); + exit(1); + } +} + +#[NoReturn] +function startDockerInstaller(array $opts): void +{ + $container = $opts['container'] ?? INSTALLER_DEFAULT_CONTAINER; + $image = INSTALLER_DEFAULT_IMAGE; + if (!dockerImageExists($image)) { + buildDockerInstallerImage($image); + } + ensureLocalInstallerTag($image, 'appwrite/appwrite:local'); + $port = $opts['port'] ?? INSTALLER_DEFAULT_PORT; + $entrypoint = isset($opts['upgrade']) ? 'upgrade' : 'install'; + + removeDockerInstallerContainer($container); + + $cwd = getcwd() ?: '.'; + $volumePath = $cwd . '/appwrite'; + $envArgs = []; + $envArgs[] = ['-e', 'APPWRITE_INSTALLER_LOCAL=1']; + if (isset($opts['mock'])) { + $envArgs[] = ['-e', 'APPWRITE_INSTALLER_MOCK=1']; + } + if (!empty($opts['locked-database'])) { + $envArgs[] = ['-e', '_APP_DB_ADAPTER=' . $opts['locked-database']]; + } + + $args = [ + 'docker', + 'run', + '-it', + '--rm', + '--name', $container, + '-p', "$port:8080", + '--volume', '/var/run/docker.sock:/var/run/docker.sock', + '--volume', "$volumePath:/usr/src/code/appwrite:rw", + ]; + foreach ($envArgs as $envPair) { + $args[] = $envPair[0]; + $args[] = $envPair[1]; + } + $args[] = '--entrypoint=' . $entrypoint; + $args[] = $image; + + $escaped = array_map('escapeshellarg', $args); + $command = implode(' ', $escaped); + passthru($command, $status); + exit($status); +} + +function handleInstallerRequest(): bool +{ + $path = $_SERVER['REQUEST_URI'] ?? ''; + $requestPath = parse_url($path, PHP_URL_PATH) ?? ''; + if ($requestPath === '' || $requestPath === '/') { + $requestPath = '/installer.phtml'; + } + $basePath = realpath(__DIR__); + if ($basePath === false) { + return false; + } + $candidate = $basePath . $requestPath; + $filePath = realpath($candidate); + if ($filePath === false || !str_starts_with($filePath, $basePath . DIRECTORY_SEPARATOR)) { + return false; + } + + $upgradeEnv = getenv('APPWRITE_INSTALLER_UPGRADE'); + if ($upgradeEnv !== false) { + $isUpgrade = $upgradeEnv === '1' || strtolower($upgradeEnv) === 'true'; + } + + $lockedDbEnv = getenv('APPWRITE_INSTALLER_LOCKED_DATABASE'); + if ($lockedDbEnv !== false && $lockedDbEnv !== '') { + $lockedDatabase = $lockedDbEnv; + } + + if (is_file($filePath)) { + $extension = pathinfo($filePath, PATHINFO_EXTENSION); + if (in_array($extension, ['html', 'phtml', 'php'], true)) { + sendInstallerHtmlHeaders(); + include $filePath; + return true; + } + return false; + } + + return false; +} + +if (PHP_SAPI === 'cli') { + $opts = getopt('', ['host::', 'port::', 'mock', 'upgrade', 'locked-database::', 'docker', 'container::', 'clean']); + $host = $opts['host'] ?? INSTALLER_DEFAULT_HOST; + $port = $opts['port'] ?? INSTALLER_DEFAULT_PORT; + $isMock = isset($opts['mock']); + if (isset($opts['clean'])) { + removeDockerInstallerContainer($opts['container'] ?? INSTALLER_DEFAULT_CONTAINER); + killInstallerDevServers(); + exit(0); + } + if (isset($opts['docker'])) { + printInstallerUrl($isMock, $host, $port); + startDockerInstaller($opts); + } + if ($isMock) { + putenv('APPWRITE_INSTALLER_MOCK=1'); + } + if (isset($opts['upgrade'])) { + putenv('APPWRITE_INSTALLER_UPGRADE=1'); + } + if (!empty($opts['locked-database'])) { + putenv('APPWRITE_INSTALLER_LOCKED_DATABASE=' . $opts['locked-database']); + } + + printInstallerUrl($isMock, $host, $port); + flush(); + startInstallerDevServer($host, $port); +} + +return handleInstallerRequest(); diff --git a/composer.json b/composer.json index 25d3c06c8d..2f2496eacd 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,12 @@ "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", "bench": "vendor/bin/phpbench run --report=benchmark", - "check": "./vendor/bin/phpstan analyse -c phpstan.neon" + "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "installer:clean": "php app/views/install/router.php --clean", + "installer:dev": "composer installer:clean && php app/views/install/router.php --docker", + "installer:dev-upgrade": "composer installer:clean && php app/views/install/router.php --docker --upgrade", + "installer:mock": "composer installer:clean && php app/views/install/router.php --mock", + "installer:mock-upgrade": "composer installer:clean && php app/views/install/router.php --mock --upgrade --locked-database=mongodb" }, "autoload": { "psr-4": { diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 9c51c4a91c..b367fb11b9 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -12,10 +12,10 @@ use Utopia\Config\Config; use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\Validator\WhiteList; class Install extends Action { + private const INSTALL_STEP_DELAY_SECONDS = 2; protected string $path = '/usr/src/code/appwrite'; protected ?string $hostPath = null; @@ -40,7 +40,7 @@ class Install extends Action $this->ensureHostPathLink(); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void + public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void { $config = Config::getParam('variables'); $defaultHTTPPort = '80'; @@ -56,7 +56,6 @@ class Install extends Action Console::success('Starting Appwrite installation...'); - // Create directory with write permissions if (!\file_exists(\dirname($this->path))) { if (!@\mkdir(\dirname($this->path), 0755, true)) { Console::error('Can\'t create directory ' . \dirname($this->path)); @@ -64,7 +63,6 @@ class Install extends Action } } - // Check for existing installation $data = @file_get_contents($this->path . '/docker-compose.yml'); $existingInstallation = $data !== false; @@ -136,7 +134,6 @@ 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'); @@ -146,7 +143,6 @@ class Install extends Action return; } - // Fall back to CLI mode if (empty($httpPort)) { $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; @@ -180,7 +176,7 @@ class Install extends Action } $input = $this->prepareEnvironmentVariables($userInput, $vars); - $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); } private function detectHostPath(string $path): ?string @@ -290,41 +286,46 @@ class Install extends Action $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'; $this->createRouterScript($routerScript, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, $isUpgrade, $lockedDatabase); - // Start PHP built-in server in background $command = \sprintf( - 'php -S %s:%d %s >/dev/null 2>&1 &', + 'php -S %s:%d %s >/dev/null 2>&1 & echo $!', $host, $port, \escapeshellarg($routerScript) ); - // Start the server + $output = []; + $exitCode = 0; \exec($command, $output, $exitCode); + $pid = isset($output[0]) ? (int) $output[0] : 0; + + \register_shutdown_function(function () use ($routerScript, $pid) { + if (\file_exists($routerScript)) { + \unlink($routerScript); + } + if ($pid > 0 && \function_exists('posix_kill')) { + @\posix_kill($pid, SIGTERM); + } + }); \sleep(3); - // Check if the server actually started $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); if ($handle === false) { Console::exit(1); } \fclose($handle); - // Wait for the server process to finish while (true) { $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); if ($handle === false) { - // Server has stopped break; } \fclose($handle); \sleep(1); } - // Cleanup if (\file_exists($routerScript)) { \unlink($routerScript); } @@ -354,14 +355,302 @@ define('NO_START', %s); define('VARS_JSON', '%s'); define('IS_UPGRADE', %s); define('LOCKED_DATABASE', %s); +define('INSTALLER_VIEW', 'installer.phtml'); +define('INSTALL_LOCK_TTL', 3600); $uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); -// Serve static files +function sanitizeInstallId($value): string +{ + if (!is_string($value)) { + return ''; + } + + $clean = preg_replace('/[^a-zA-Z0-9_-]/', '', $value); + if (!is_string($clean)) { + return ''; + } + + return substr($clean, 0, 64); +} + +function progressFilePath(string $installId): string +{ + return sys_get_temp_dir() . '/appwrite-install-' . $installId . '.json'; +} + +function globalLockPath(): string +{ + return sys_get_temp_dir() . '/appwrite-install-lock.json'; +} + +function isGlobalLockActive(?array $lock): bool +{ + if (!$lock || !isset($lock['updatedAt'])) { + return false; + } + + if (time() - (int) $lock['updatedAt'] > 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'"); +} + if ($uri !== '/' && $uri !== '') { - $filePath = PUBLIC_PATH . $uri; - if (file_exists($filePath) && is_file($filePath)) { - // Determine content type + $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)) { $extension = pathinfo($filePath, PATHINFO_EXTENSION); $mimeTypes = [ 'css' => 'text/css', @@ -387,10 +676,51 @@ if ($uri !== '/' && $uri !== '') { } } -// Handle POST request (installation) -if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { +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; +} + +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) { @@ -399,52 +729,249 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { 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(); - // Prepare user inputs - use locked database if in upgrade mode - $userInput = [ + if ($wantsStream) { + sendSseEvent('install-id', ['installId' => $installId]); + } + + updateGlobalLock($installId, 'in-progress'); + + $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'] ?? '', ]; - // Use the prepareEnvironmentVariables method to merge with defaults + 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]) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Installation payload mismatch']); + exit; + } + } + + $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) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Installation payload mismatch']); + exit; + } + } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Installation payload mismatch']); + exit; + } + } + + $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; + } + $vars = json_decode(VARS_JSON, true); - $envVars = $installer->prepareEnvironmentVariables($userInput, $vars); + $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 + NO_START, + $progress, + $retryStep, + IS_UPGRADE ); - echo json_encode(['success' => true, 'message' => 'Installation completed successfully']); + if ($wantsStream) { + sendSseEvent('done', ['installId' => $installId, 'success' => true]); + } else { + echo json_encode([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully' + ]); + } + updateGlobalLock($installId, 'completed'); - // Stop the server after successful installation register_shutdown_function(function() { - sleep(2); // Give time for response to be sent + sleep(2); posix_kill(posix_getpid(), SIGTERM); }); } catch (\Throwable $e) { http_response_code(500); - echo json_encode(['success' => false, 'message' => $e->getMessage()]); + 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 === '') { - header('Content-Type: text/html'); + sendInstallerHtmlHeaders(); $vars = json_decode(VARS_JSON, true); $defaultHttpPort = DEFAULT_HTTP_PORT; @@ -452,11 +979,10 @@ if ($uri === '/' || $uri === '') { $isUpgrade = IS_UPGRADE; $lockedDatabase = LOCKED_DATABASE; - include APP_VIEWS_PATH . '/installer.phtml'; + include APP_VIEWS_PATH . '/' . INSTALLER_VIEW; exit; } -// 404 http_response_code(404); echo '404 Not Found'; PHP; @@ -488,7 +1014,6 @@ PHP; $password = new Password(); $token = new Token(); - // Start with all defaults foreach ($vars as $var) { if (!empty($var['filter']) && $var['filter'] === 'token') { $input[$var['name']] = $token->generate(); @@ -499,14 +1024,12 @@ PHP; } } - // Override with user inputs foreach ($userInput as $key => $value) { if ($value !== null && $value !== '') { $input[$key] = $value; } } - // Set database-specific connection details $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; if ($database === 'mongodb') { $input['_APP_DB_HOST'] = 'mongodb'; @@ -519,26 +1042,57 @@ PHP; return $input; } + private function reportProgress(?callable $progress, string $step, string $status, string $message, array $details = []): void + { + if (!$progress) { + return; + } + + try { + $progress($step, $status, $message, $details); + } catch (\Throwable $e) { + } + } + + private function delayProgress(?callable $progress, string $status): void + { + if ($progress && $status !== 'error') { + sleep(self::INSTALL_STEP_DELAY_SECONDS); + } + } + public function performInstallation( string $httpPort, string $httpsPort, string $organization, string $image, array $input, - bool $noStart + bool $noStart, + ?callable $progress = null, + ?string $resumeFromStep = null, + bool $isUpgrade = false ): void { - // Check if we're running in CLI context $isCLI = php_sapi_name() === 'cli'; + if (getenv('APPWRITE_INSTALLER_LOCAL')) { + $organization = 'appwrite'; + $image = 'appwrite'; + } + $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml'); $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml'); $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + $version = \defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest'; + if (getenv('APPWRITE_INSTALLER_LOCAL')) { + $version = 'local'; + } + $templateForCompose ->setParam('httpPort', $httpPort) ->setParam('httpsPort', $httpsPort) - ->setParam('version', 'next') + ->setParam('version', $version) ->setParam('organization', $organization) ->setParam('image', $image) ->setParam('database', $database) @@ -546,49 +1100,170 @@ PHP; $templateForEnv->setParam('vars', $input); - if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { - throw new \Exception('Failed to save Docker Compose file'); - } - - if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - throw new \Exception('Failed to save environment variables file'); - } - - // Copy MongoDB entrypoint script for replica set setup - if ($database === 'mongodb') { - $mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh'; - - if (file_exists($mongoEntrypoint)) { - copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); + $steps = ['docker-compose', 'env-vars', 'docker-containers']; + $startIndex = 0; + if ($resumeFromStep !== null) { + $resumeIndex = array_search($resumeFromStep, $steps, true); + if ($resumeIndex !== false) { + $startIndex = $resumeIndex; } } - if (!$noStart) { - $env = ''; - foreach ($input as $key => $value) { - if ($value) { - $env .= $key . '=' . \escapeshellarg($value) . ' '; + $currentStep = null; + + $messages = $isUpgrade ? [ + 'config-files' => [ + 'start' => 'Updating configuration files...', + 'done' => 'Configuration files updated', + ], + 'docker-compose' => [ + 'start' => 'Updating Docker Compose file...', + 'done' => 'Docker Compose file updated', + ], + 'env-vars' => [ + 'start' => 'Updating environment variables...', + 'done' => 'Environment variables updated', + ], + 'docker-containers' => [ + 'start' => 'Restarting Docker containers...', + 'done' => 'Docker containers restarted', + ], + ] : [ + 'config-files' => [ + 'start' => 'Creating configuration files...', + 'done' => 'Configuration files created', + ], + 'docker-compose' => [ + 'start' => 'Generating Docker Compose file...', + 'done' => 'Docker Compose file generated', + ], + 'env-vars' => [ + 'start' => 'Configuring environment variables...', + 'done' => 'Environment variables configured', + ], + 'docker-containers' => [ + 'start' => 'Starting Docker containers...', + 'done' => 'Docker containers started', + ], + ]; + + try { + if ($startIndex <= 1) { + $this->reportProgress($progress, 'config-files', 'in-progress', $messages['config-files']['start']); + $this->delayProgress($progress, 'in-progress'); + } + + if ($startIndex <= 0) { + $currentStep = 'docker-compose'; + $this->reportProgress($progress, 'docker-compose', 'in-progress', $messages['docker-compose']['start']); + $this->delayProgress($progress, 'in-progress'); + + if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { + throw new \Exception('Failed to save Docker Compose file'); + } + + $this->reportProgress($progress, 'docker-compose', 'completed', $messages['docker-compose']['done']); + } + + if ($startIndex <= 1) { + $currentStep = 'env-vars'; + $this->reportProgress($progress, 'env-vars', 'in-progress', $messages['env-vars']['start']); + $this->delayProgress($progress, 'in-progress'); + + if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { + throw new \Exception('Failed to save environment variables file'); + } + + $this->reportProgress($progress, 'env-vars', 'completed', $messages['env-vars']['done']); + $this->reportProgress($progress, 'config-files', 'completed', $messages['config-files']['done']); + } + + if ($database === 'mongodb') { + $mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh'; + + if (file_exists($mongoEntrypoint)) { + copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); } } - if ($isCLI) { - Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); - } + if (!$noStart && $startIndex <= 2) { + $currentStep = 'docker-containers'; + $this->reportProgress($progress, 'docker-containers', 'in-progress', $messages['docker-containers']['start']); + $this->delayProgress($progress, 'in-progress'); + $envVars = getenv(); + if (!is_array($envVars)) { + $envVars = []; + } + foreach ($input as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (!preg_match('/^[A-Z0-9_]+$/', $key)) { + throw new \Exception('Invalid environment variable name'); + } + $envVars[$key] = (string) $value; + } - $command = "$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes 2>&1"; - \exec($command, $output, $exit); + if ($isCLI) { + Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); + } - if ($exit !== 0) { - throw new \Exception('Failed to start Appwrite: ' . implode("\n", $output)); - } + $command = [ + 'docker', + 'compose', + '--project-directory', + $this->path, + 'up', + '-d', + '--remove-orphans', + '--renew-anon-volumes' + ]; + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = \proc_open($command, $descriptorSpec, $pipes, null, $envVars); + if (!is_resource($process)) { + throw new \Exception('Failed to start Docker Compose'); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + if (is_resource($pipes[1])) { + fclose($pipes[1]); + } + if (is_resource($pipes[2])) { + fclose($pipes[2]); + } + $exit = \proc_close($process); - if ($isCLI) { - Console::success('Appwrite installed successfully'); + if ($exit !== 0) { + $output = trim(($stderr ?: '') . ($stdout ?: '')); + $previous = $output !== '' ? new \RuntimeException($output) : null; + throw new \RuntimeException('Failed to start containers', 0, $previous); + } + + $this->reportProgress($progress, 'docker-containers', 'completed', $messages['docker-containers']['done']); + + if ($isCLI) { + Console::success('Appwrite installed successfully'); + } + } else { + if ($isCLI) { + Console::success('Installation files created. Run "docker compose up -d" to start Appwrite'); + } } - } else { - if ($isCLI) { - Console::success('Installation files created. Run "docker compose up -d" to start Appwrite'); + } catch (\Throwable $e) { + if ($currentStep) { + $details = [ + 'trace' => $e->getTraceAsString() + ]; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + $this->reportProgress($progress, $currentStep, 'error', $e->getMessage(), $details); } + throw $e; } } } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 09463a2605..ed2970cf41 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Tasks; use Utopia\CLI\Console; +use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Text; @@ -30,7 +31,7 @@ class Upgrade extends Install ->callback($this->action(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void + public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void { // Check for previous installation $data = @file_get_contents($this->path . '/docker-compose.yml'); @@ -47,7 +48,7 @@ class Upgrade extends Install $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb'); $this->lockedDatabase = $database; - parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, $database); + parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, true); } protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void From 58440e70acd26d2d21345bedcd78d37a9c330791 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 22 Jan 2026 18:12:41 +0530 Subject: [PATCH 04/46] misc. --- app/views/install/installer.phtml | 3 +- app/views/install/installer/css/styles.css | 20 ++- .../install/installer/icons/appwrite-mark.svg | 4 + app/views/install/installer/js/constants.js | 1 + app/views/install/installer/js/installer.js | 2 +- app/views/install/installer/js/steps.js | 66 +++++---- .../installer/templates/steps/step-1.phtml | 6 +- .../installer/templates/steps/step-3.phtml | 4 +- .../installer/templates/steps/step-4.phtml | 2 +- app/views/install/router.php | 2 +- src/Appwrite/Platform/Tasks/Install.php | 127 +++++++++++++----- 11 files changed, 169 insertions(+), 68 deletions(-) create mode 100644 app/views/install/installer/icons/appwrite-mark.svg diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index fa7bb467c2..a5d08b72d3 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -47,7 +47,8 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() - <?php echo $isUpgrade ? 'Appwrite Upgrade' : 'Appwrite Installation'; ?> + <?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 c3531f7669..4114483c70 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -212,22 +212,30 @@ body { } .installer-page[data-step='4'] .installer-backdrop { - opacity: 1; + opacity: 0.4; } .installer-blob { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + left: 0; + top: 0; + --blob-x: 0px; + --blob-y: 0px; + transform: translate(var(--blob-x), var(--blob-y)) scale(1); + transform-origin: center; + animation: none; } .installer-blob.blob-one { - transform: translate(-50%, -50%) translate(-111px, -100px); + --blob-x: 609px; + --blob-y: 412px; + opacity: 0.4; } .installer-blob.blob-two { - transform: translate(-50%, -50%) translate(-278px, -107px); + --blob-x: 442px; + --blob-y: 405px; + opacity: 0.28; } .installer-card { diff --git a/app/views/install/installer/icons/appwrite-mark.svg b/app/views/install/installer/icons/appwrite-mark.svg new file mode 100644 index 0000000000..7dff2b5fc4 --- /dev/null +++ b/app/views/install/installer/icons/appwrite-mark.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js index e7c3256de2..0718aae7a1 100644 --- a/app/views/install/installer/js/constants.js +++ b/app/views/install/installer/js/constants.js @@ -4,6 +4,7 @@ errorClearMs: 180, installPollIntervalMs: 4000, installFallbackDelayMs: 12000, + redirectDelayMs: 500, mockStepDelayMs: 1800, progressTransitionDelayMs: 200, mockErrorDetails: { diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js index ce372e04a0..4f5dac338f 100644 --- a/app/views/install/installer/js/installer.js +++ b/app/views/install/installer/js/installer.js @@ -34,7 +34,7 @@ } const prev = stepFlow[index - 1] ?? null; const next = stepFlow[index + 1] ?? null; - const label = next === 4 ? (isUpgrade ? 'Upgrade' : 'Install') : 'Next'; + const label = next === 4 ? (isUpgrade ? 'Update' : 'Install') : 'Next'; config[step] = { back: { target: prev }, next: { label, target: next } diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index dff4b3e831..c8cd07a55e 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -1,5 +1,6 @@ (() => { const INSTALL_LOCK_KEY = 'appwrite-install-lock'; + const INSTALL_ID_KEY = 'appwrite-install-id'; const isMockMode = () => document.body?.dataset.installMode === 'mock' || (typeof navigator !== 'undefined' && navigator.webdriver); const getBodyDataset = () => document.body?.dataset ?? {}; @@ -62,6 +63,7 @@ errorClear: CONSTANTS.errorClearMs ?? 180, installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000, installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, + redirectDelay: CONSTANTS.redirectDelayMs ?? 500, mockStepDelay: CONSTANTS.mockStepDelayMs ?? 1800, progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140 }; @@ -310,7 +312,9 @@ if (data.lockedDatabase) { formState.database = data.lockedDatabase; } - setStateIfEmpty('database', data.defaultDatabase); + if (!isUpgradeMode()) { + setStateIfEmpty('database', data.defaultDatabase); + } }; const toDatabaseLabel = (value) => { @@ -544,6 +548,15 @@ 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'); @@ -821,8 +834,6 @@ const row = fragment.querySelector('.install-row'); if (!row) return null; const toggle = row.querySelector('[data-install-toggle]'); - const copyButton = row.querySelector('[data-install-copy]'); - const copyWrapper = copyButton?.closest('.tooltip-wrapper'); const setOpenState = (isOpen) => { row.classList.toggle('is-open', isOpen); if (toggle) { @@ -853,22 +864,6 @@ toggleRow(); }); } - if (copyWrapper) { - copyWrapper.addEventListener('mouseenter', () => resetTooltipText(copyWrapper)); - copyWrapper.addEventListener('focusin', () => resetTooltipText(copyWrapper)); - } - if (copyButton) { - copyButton.addEventListener('click', (event) => { - event.stopPropagation(); - const traceNode = row.querySelector('[data-install-trace]'); - const text = traceNode?.textContent?.trim() || ''; - copyToClipboard(text); - if (copyWrapper) { - const successText = copyWrapper.dataset.tooltipSuccess || 'Copied'; - setTooltipText(copyWrapper, successText); - } - }); - } updateInstallRow(row, step, 'in-progress'); return row; }; @@ -938,9 +933,21 @@ window.location.href = url; }; + const notifyInstallComplete = (installId) => { + if (isMockProgressMode()) return Promise.resolve(); + if (!installId) return Promise.resolve(); + return fetch('/install/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ installId }) + }).catch(() => {}); + }; + const getStoredInstallId = () => { try { - return sessionStorage.getItem('appwrite-install-id'); + return sessionStorage.getItem(INSTALL_ID_KEY); } catch (error) { return null; } @@ -948,13 +955,13 @@ const storeInstallId = (installId) => { try { - sessionStorage.setItem('appwrite-install-id', installId); + sessionStorage.setItem(INSTALL_ID_KEY, installId); } catch (error) {} }; const clearInstallId = () => { try { - sessionStorage.removeItem('appwrite-install-id'); + sessionStorage.removeItem(INSTALL_ID_KEY); } catch (error) {} }; @@ -1350,8 +1357,21 @@ return; } if (event === 'done') { + const lastStep = INSTALLATION_STEPS[INSTALLATION_STEPS.length - 1]; + if (lastStep) { + const lastState = progressState.get(lastStep.id); + if (!lastState || lastState.status !== 'completed') { + progressState.set(lastStep.id, { + status: 'completed', + message: lastStep.done, + details: lastState?.details + }); + renderProgress(); + } + } finalizeInstall(); - redirectToApp(); + notifyInstallComplete(activeInstall?.installId); + setTimeout(() => redirectToApp(), TIMINGS.redirectDelay); return; } if (event === 'error') { diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml index 1afdd7ebe9..c1a036ddf1 100644 --- a/app/views/install/installer/templates/steps/step-1.phtml +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -7,7 +7,7 @@ $defaultHttpsPort = $defaultHttpsPort ?? '443'; $defaultEmailCertificates = $defaultEmailCertificates ?? ''; $defaultAssistantOpenAIKey = $defaultAssistantOpenAIKey ?? ''; $defaultDatabase = $defaultDatabase ?? 'mongodb'; -$selectedDatabase = $lockedDatabase ?: $defaultDatabase; +$selectedDatabase = $isUpgrade ? '' : ($lockedDatabase ?: $defaultDatabase); $hostnameValue = htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); $httpPortValue = htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); $httpsPortValue = htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); @@ -17,9 +17,9 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey,
-

+

- +

diff --git a/app/views/install/installer/templates/steps/step-3.phtml b/app/views/install/installer/templates/steps/step-3.phtml index 1d3a80c593..9e34779a67 100644 --- a/app/views/install/installer/templates/steps/step-3.phtml +++ b/app/views/install/installer/templates/steps/step-3.phtml @@ -15,9 +15,9 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
-

+

- +

diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml index b02bc29e1a..76ca9a9d54 100644 --- a/app/views/install/installer/templates/steps/step-4.phtml +++ b/app/views/install/installer/templates/steps/step-4.phtml @@ -6,7 +6,7 @@ $isUpgrade = $isUpgrade ?? false;
- +
diff --git a/app/views/install/router.php b/app/views/install/router.php index 8e97455134..e615f43652 100644 --- a/app/views/install/router.php +++ b/app/views/install/router.php @@ -152,7 +152,7 @@ function startDockerInstaller(array $opts): void removeDockerInstallerContainer($container); $cwd = getcwd() ?: '.'; - $volumePath = $cwd . '/appwrite'; + $volumePath = $cwd; $envArgs = []; $envArgs[] = ['-e', 'APPWRITE_INSTALLER_LOCAL=1']; if (isset($opts['mock'])) { diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index b367fb11b9..1ede049b19 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -56,6 +56,9 @@ class Install extends Action Console::success('Starting Appwrite installation...'); + $isLocalInstall = !empty(getenv('APPWRITE_INSTALLER_LOCAL')); + + // Create directory with write permissions if (!\file_exists(\dirname($this->path))) { if (!@\mkdir(\dirname($this->path), 0755, true)) { Console::error('Can\'t create directory ' . \dirname($this->path)); @@ -63,13 +66,16 @@ class Install extends Action } } + // Check for existing installation $data = @file_get_contents($this->path . '/docker-compose.yml'); $existingInstallation = $data !== false; if ($existingInstallation) { $time = \time(); - Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup'); - file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data); + if (!$isLocalInstall) { + Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup'); + file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data); + } $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); $oldVersion = $appwrite?->getImageVersion(); @@ -106,8 +112,10 @@ class Install extends Action $envData = @file_get_contents($this->path . '/.env'); if ($envData !== false) { - Console::info('Env file found, creating backup: .env.' . $time . '.backup'); - file_put_contents($this->path . '/.env.' . $time . '.backup', $envData); + if (!$isLocalInstall) { + Console::info('Env file found, creating backup: .env.' . $time . '.backup'); + file_put_contents($this->path . '/.env.' . $time . '.backup', $envData); + } $env = new Env($envData); foreach ($env->list() as $key => $value) { @@ -134,6 +142,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'); @@ -143,6 +152,7 @@ class Install extends Action return; } + // Fall back to CLI mode if (empty($httpPort)) { $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; @@ -286,9 +296,11 @@ class Install extends Action $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'; $this->createRouterScript($routerScript, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, $isUpgrade, $lockedDatabase); + // Start PHP built-in server in background $command = \sprintf( 'php -S %s:%d %s >/dev/null 2>&1 & echo $!', $host, @@ -296,6 +308,7 @@ class Install extends Action \escapeshellarg($routerScript) ); + // Start the server $output = []; $exitCode = 0; \exec($command, $output, $exitCode); @@ -311,12 +324,14 @@ class Install extends Action }); \sleep(3); + // Check if the server actually started $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); if ($handle === false) { Console::exit(1); } \fclose($handle); + // Wait for the server process to finish while (true) { $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); if ($handle === false) { @@ -326,6 +341,7 @@ class Install extends Action \sleep(1); } + // Cleanup if (\file_exists($routerScript)) { \unlink($routerScript); } @@ -390,6 +406,10 @@ function isGlobalLockActive(?array $lock): bool return false; } + if (isset($lock['status']) && in_array($lock['status'], ['completed', 'error'], true)) { + return false; + } + if (time() - (int) $lock['updatedAt'] > INSTALL_LOCK_TTL) { return false; } @@ -630,6 +650,7 @@ function sendInstallerHtmlHeaders(): void 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); @@ -651,6 +672,7 @@ if ($uri !== '/' && $uri !== '') { } if ($filePath && is_file($filePath)) { + // Determine content type $extension = pathinfo($filePath, PATHINFO_EXTENSION); $mimeTypes = [ 'css' => 'text/css', @@ -698,6 +720,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && $uri === '/install/status') { 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; @@ -813,6 +865,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { updateGlobalLock($installId, 'in-progress'); + // Prepare user inputs - use locked database if in upgrade mode $payloadInput = [ '_APP_ENV' => 'production', '_APP_OPENSSL_KEY_V1' => $input['opensslKey'] ?? '', @@ -834,9 +887,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { ]; foreach ($fieldsToCompare as $field) { if (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => 'Installation payload mismatch']); - exit; + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + respondBadRequest('Installation payload mismatch', $wantsStream); } } @@ -851,14 +905,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { $incomingHash = hashSensitiveValue((string) ($input[$field] ?? '')); if (isset($stored[$hashField])) { if ((string) $stored[$hashField] !== $incomingHash) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => 'Installation payload mismatch']); - exit; + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + respondBadRequest('Installation payload mismatch', $wantsStream); } } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => 'Installation payload mismatch']); - exit; + if ($installId !== '') { + updateGlobalLock($installId, 'error'); + } + respondBadRequest('Installation payload mismatch', $wantsStream); } } @@ -870,6 +926,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { $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); @@ -909,6 +966,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { } }; + // Call performInstallation method $installer->performInstallation( $input['httpPort'] ?? DEFAULT_HTTP_PORT, $input['httpsPort'] ?? DEFAULT_HTTPS_PORT, @@ -932,11 +990,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { } updateGlobalLock($installId, 'completed'); - register_shutdown_function(function() { - sleep(2); - posix_kill(posix_getpid(), SIGTERM); - }); - } catch (\Throwable $e) { http_response_code(500); if ($installId !== '') { @@ -970,6 +1023,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') { exit; } +// Serve installer UI if ($uri === '/' || $uri === '') { sendInstallerHtmlHeaders(); @@ -983,6 +1037,7 @@ if ($uri === '/' || $uri === '') { exit; } +// 404 http_response_code(404); echo '404 Not Found'; PHP; @@ -1014,6 +1069,7 @@ PHP; $password = new Password(); $token = new Token(); + // Start with all defaults foreach ($vars as $var) { if (!empty($var['filter']) && $var['filter'] === 'token') { $input[$var['name']] = $token->generate(); @@ -1024,12 +1080,14 @@ PHP; } } + // Override with user inputs foreach ($userInput as $key => $value) { if ($value !== null && $value !== '') { $input[$key] = $value; } } + // Set database-specific connection details $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; if ($database === 'mongodb') { $input['_APP_DB_HOST'] = 'mongodb'; @@ -1073,6 +1131,9 @@ PHP; bool $isUpgrade = false ): void { $isCLI = php_sapi_name() === 'cli'; + $useExistingConfig = !empty(getenv('APPWRITE_INSTALLER_LOCAL')) + && file_exists($this->path . '/docker-compose.yml') + && file_exists($this->path . '/.env'); if (getenv('APPWRITE_INSTALLER_LOCAL')) { $organization = 'appwrite'; @@ -1158,8 +1219,10 @@ PHP; $this->reportProgress($progress, 'docker-compose', 'in-progress', $messages['docker-compose']['start']); $this->delayProgress($progress, 'in-progress'); - if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { - throw new \Exception('Failed to save Docker Compose file'); + if (!$useExistingConfig) { + if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { + throw new \Exception('Failed to save Docker Compose file'); + } } $this->reportProgress($progress, 'docker-compose', 'completed', $messages['docker-compose']['done']); @@ -1170,15 +1233,17 @@ PHP; $this->reportProgress($progress, 'env-vars', 'in-progress', $messages['env-vars']['start']); $this->delayProgress($progress, 'in-progress'); - if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - throw new \Exception('Failed to save environment variables file'); + if (!$useExistingConfig) { + if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { + throw new \Exception('Failed to save environment variables file'); + } } $this->reportProgress($progress, 'env-vars', 'completed', $messages['env-vars']['done']); $this->reportProgress($progress, 'config-files', 'completed', $messages['config-files']['done']); } - if ($database === 'mongodb') { + if ($database === 'mongodb' && !$useExistingConfig) { $mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh'; if (file_exists($mongoEntrypoint)) { @@ -1194,14 +1259,16 @@ PHP; if (!is_array($envVars)) { $envVars = []; } - foreach ($input as $key => $value) { - if ($value === null || $value === '') { - continue; + if (!$useExistingConfig) { + foreach ($input as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (!preg_match('/^[A-Z0-9_]+$/', $key)) { + throw new \Exception('Invalid environment variable name'); + } + $envVars[$key] = (string) $value; } - if (!preg_match('/^[A-Z0-9_]+$/', $key)) { - throw new \Exception('Invalid environment variable name'); - } - $envVars[$key] = (string) $value; } if ($isCLI) { From 3d86f67acb73dad9e2fccfd6dc1b7c6a302afcc7 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 22 Jan 2026 19:52:46 +0530 Subject: [PATCH 05/46] 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); From 5144b1ce88a05bd73432686b90194ad20e01fda2 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sat, 24 Jan 2026 16:30:51 +0530 Subject: [PATCH 06/46] update: splits. fix: broken SSE. fix: upgrade flow not working. feat: csrf tokens for better security. fix: local testing mode with wrong paths. feat: csrf validations pre-post hook plus proper permissions handling on files. feat: js modules for better handling of js operations on web installer frontend ui. --- .gitignore | 4 + app/views/install/compose.phtml | 8 +- app/views/install/installer.phtml | 22 +- app/views/install/installer/css/styles.css | 140 +- app/views/install/installer/js/constants.js | 2 +- app/views/install/installer/js/installer.js | 21 +- .../install/installer/js/modules/context.js | 119 ++ .../install/installer/js/modules/progress.js | 909 ++++++++++ .../install/installer/js/modules/state.js | 153 ++ .../install/installer/js/modules/toast.js | 95 + app/views/install/installer/js/modules/ui.js | 268 +++ .../installer/js/modules/validation.js | 111 ++ app/views/install/installer/js/steps.js | 1546 ++--------------- app/views/install/router.php | 260 --- composer.json | 10 +- .../Platform/Installer/HttpHandler.php | 576 ++++++ .../Platform/Installer/Runtime/Config.php | 217 +++ .../Platform/Installer/Runtime/State.php | 394 +++++ src/Appwrite/Platform/Installer/Server.php | 992 +++-------- src/Appwrite/Platform/Tasks/Install.php | 551 ++++-- src/Appwrite/Platform/Tasks/Upgrade.php | 23 +- 21 files changed, 3814 insertions(+), 2607 deletions(-) create mode 100644 app/views/install/installer/js/modules/context.js create mode 100644 app/views/install/installer/js/modules/progress.js create mode 100644 app/views/install/installer/js/modules/state.js create mode 100644 app/views/install/installer/js/modules/toast.js create mode 100644 app/views/install/installer/js/modules/ui.js create mode 100644 app/views/install/installer/js/modules/validation.js delete mode 100644 app/views/install/router.php create mode 100644 src/Appwrite/Platform/Installer/HttpHandler.php create mode 100644 src/Appwrite/Platform/Installer/Runtime/Config.php create mode 100644 src/Appwrite/Platform/Installer/Runtime/State.php diff --git a/.gitignore b/.gitignore index 4188afacf9..b07aed4b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ appwrite.config.json /.zed/ playwright-report test-results + +# Web installer test files +docker-compose.web-installer.yml +.env.web-installer diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 91d6b33cfb..d07949ba6e 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -12,6 +12,7 @@ $version = $this->getParam('version', ''); $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $dbService = $this->getParam('database'); +$hostPath = $this->getParam('hostPath', ''); ?>services: traefik: @@ -62,6 +63,9 @@ $dbService = $this->getParam('database'); - traefik.http.routers.appwrite_api_https.service=appwrite_api - traefik.http.routers.appwrite_api_https.tls=true volumes: + + - :/usr/src/code:rw + - appwrite-uploads:/storage/uploads:rw - appwrite-imports:/storage/imports:rw - appwrite-cache:/storage/cache:rw @@ -850,6 +854,7 @@ $dbService = $this->getParam('database'); - _APP_DB_USER - _APP_DB_PASS + appwrite-assistant: image: appwrite/assistant:0.8.3 container_name: appwrite-assistant @@ -859,7 +864,8 @@ $dbService = $this->getParam('database'); - appwrite environment: - _APP_ASSISTANT_OPENAI_API_KEY - + + appwrite-browser: image: appwrite/browser:0.3.2 container_name: appwrite-browser diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index 69146b9f00..db97dc7bdb 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -1,6 +1,6 @@ @@ -51,10 +57,18 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() <?php echo $isUpgrade ? 'Appwrite Update' : 'Appwrite Installation'; ?> + + + + + + + + @@ -139,5 +153,7 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time()
+ +
diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index 9c4ac1c66f..a5f3d126cd 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -36,12 +36,13 @@ --bgcolor-neutral-tertiary: var(--neutral-50); --bgcolor-neutral-invert-weaker: var(--neutral-500); --bgcolor-neutral-invert-weak: var(--neutral-700); - --bgcolor-success-weak: rgba(16, 185, 129, 0.16); --bgcolor-accent: var(--brand-pink-500); --bgcolor-accent-secondary: var(--brand-pink-600); --bgcolor-accent-tertiary: var(--brand-pink-700); + --bgcolor-success-weak: rgba(16, 185, 129, 0.16); --bgcolor-warning-weaker: rgba(254, 124, 67, 0.04); --bgcolor-warning-weak: rgba(254, 124, 67, 0.16); + --bgcolor-error: var(--web-red-500); --bgcolor-error-weaker: rgba(255, 69, 58, 0.04); /* Foreground colors */ @@ -55,8 +56,8 @@ --fgcolor-on-success-weak: rgba(10, 113, 79, 1); --fgcolor-warning: rgba(97, 37, 10, 1); --fgcolor-on-warning-weak: var(--web-orange-700); - --fgcolor-success: rgba(16, 185, 129, 1); --fgcolor-error: var(--web-red-700); + --fgcolor-on-error: var(--neutral-0); /* Border colors */ --border-neutral: var(--neutral-50); @@ -169,6 +170,141 @@ --fgColor-on-accent: var(--fgcolor-on-accent); } +.installer-toast-stack { + position: fixed; + top: var(--base-12); + right: var(--base-12); + display: flex; + flex-direction: column; + gap: var(--space-4); + z-index: 60; + pointer-events: none; +} + +.installer-toast { + inline-size: 24rem; + display: inline-flex; + align-items: start; + justify-content: space-between; + gap: var(--space-6); + padding: var(--space-4); + border-radius: var(--border-radius-m); + border: var(--border-width-s) solid var(--border-neutral); + background: var(--bgcolor-neutral-primary); + box-shadow: + 0px 2px 12px 0px rgba(0, 0, 0, 0.02), + 0px 6px 8px 0px rgba(0, 0, 0, 0.02); + pointer-events: auto; + opacity: 1; + transform: translate3d(0, 0, 0); + will-change: transform, opacity; + transition: + transform 400ms cubic-bezier(0.33, 1, 0.68, 1), + opacity 400ms cubic-bezier(0.33, 1, 0.68, 1); +} + +.installer-toast.is-entering { + opacity: 0; + transform: translate3d(50px, 0, 0); +} + +.installer-toast.is-leaving { + opacity: 0; + transform: translate3d(50px, 0, 0); + pointer-events: none; +} + +.installer-toast-content { + display: flex; + align-items: start; + gap: var(--space-6); +} + +.installer-toast-body { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-block: auto; +} + +.installer-toast-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-s); + background: var(--bgcolor-neutral-invert-weaker); + color: var(--fgcolor-on-invert); + flex-shrink: 0; +} + +.installer-toast-icon svg { + width: 16px; + height: 16px; + display: block; +} + +.installer-toast-icon[data-status='error'] { + background: var(--bgcolor-error); + color: var(--fgcolor-on-error); +} + +.installer-toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + border: var(--border-width-s) solid transparent; + border-radius: var(--border-radius-s); + background: transparent; + color: var(--fgcolor-neutral-tertiary); + cursor: pointer; + transition: all 0.15s ease-in-out; +} + +.installer-toast-close svg { + width: 16px; + height: 16px; + display: block; +} + +.installer-toast-close:hover { + color: var(--fgcolor-neutral-secondary); + background: var(--overlay-neutral-hover); +} + +.installer-toast-close:active { + color: var(--fgcolor-neutral-secondary); + background: var(--overlay-neutral-pressed); +} + +.installer-toast-close:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); + outline-offset: var(--border-width-s); +} + +@media (min-width: 768px) { + .installer-toast-stack { + top: var(--base-24); + right: var(--base-24); + } +} + +@media (max-width: 640px) { + .installer-toast-stack { + left: 0; + right: 0; + top: var(--base-12); + padding: 0 var(--space-4); + align-items: stretch; + } + + .installer-toast { + inline-size: 100%; + } +} + * { box-sizing: border-box; } diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js index 06fd531a6d..ec2f3ecee7 100644 --- a/app/views/install/installer/js/constants.js +++ b/app/views/install/installer/js/constants.js @@ -4,7 +4,7 @@ errorClearMs: 180, installPollIntervalMs: 4000, installFallbackDelayMs: 12000, - redirectDelayMs: 500, + redirectDelayMs: 2500, mockStepDelayMs: 1800, progressTransitionDelayMs: 320, progressCompleteDelayMs: 140, diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js index 4f5dac338f..893bbf6e4e 100644 --- a/app/views/install/installer/js/installer.js +++ b/app/views/install/installer/js/installer.js @@ -9,6 +9,10 @@ if (!stepContainer || !installerCard) return; + const { isMockToastMode } = window.InstallerStepsContext || {}; + const { validateInstallRequest } = window.InstallerStepsProgress || {}; + const { showToast } = window.InstallerToast || {}; + const isUpgrade = document.body?.dataset.upgrade === 'true'; const stepFlow = isUpgrade ? [1, 3, 4] : [1, 2, 3, 4]; const cardSteps = stepFlow.filter((step) => step !== 4); @@ -378,7 +382,7 @@ loadStep(targetStep, pushState); }; - document.addEventListener('click', (event) => { + document.addEventListener('click', async (event) => { const button = event.target.closest('[data-step-target]'); if (!button || button.disabled) return; event.preventDefault(); @@ -397,6 +401,21 @@ } } } + if (action === 'next' && String(target) === '4' && typeof isMockToastMode === 'function' && isMockToastMode()) { + showToast?.({ + status: 'error', + title: 'Session expired', + description: 'Refresh the page and try again.', + dismissible: true + }); + return; + } + if (action === 'next' && String(target) === '4' && typeof validateInstallRequest === 'function') { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + } if (isInstallLocked() && Number(target) !== 4) { requestStep(4, true); return; diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js new file mode 100644 index 0000000000..8e941cabe7 --- /dev/null +++ b/app/views/install/installer/js/modules/context.js @@ -0,0 +1,119 @@ +(() => { + const getBodyDataset = () => document.body?.dataset ?? {}; + const isUpgradeMode = () => getBodyDataset().upgrade === 'true'; + const getLockedDatabase = () => getBodyDataset().lockedDatabase || ''; + const isMockMode = () => document.body?.dataset.installMode === 'mock' + || (typeof navigator !== 'undefined' && navigator.webdriver); + const isMockErrorMode = () => { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + return params.get('mock-error') === '1'; + }; + const getMockToastParam = () => { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + return params.get('mock-toast') === '1'; + }; + const MOCK_TOAST_ENABLED = getMockToastParam(); + const isMockToastMode = () => MOCK_TOAST_ENABLED; + const isMockProgressMode = () => isMockMode() || isMockErrorMode(); + + const STEP_IDS = Object.freeze({ + CONFIG_FILES: 'config-files', + DOCKER_COMPOSE: 'docker-compose', + ENV_VARS: 'env-vars', + DOCKER_CONTAINERS: 'docker-containers' + }); + + const STATUS = Object.freeze({ + IN_PROGRESS: 'in-progress', + COMPLETED: 'completed', + ERROR: 'error' + }); + + const SSE_EVENTS = Object.freeze({ + PING: 'ping', + INSTALL_ID: 'install-id', + PROGRESS: 'progress', + DONE: 'done', + ERROR: 'error' + }); + + const buildInstallationSteps = (upgrade) => (upgrade ? [ + { + id: STEP_IDS.CONFIG_FILES, + inProgress: 'Updating configuration files...', + done: 'Configuration files updated' + }, + { + id: STEP_IDS.DOCKER_COMPOSE, + inProgress: 'Updating Docker Compose file...', + done: 'Docker Compose file updated' + }, + { + id: STEP_IDS.ENV_VARS, + inProgress: 'Updating environment variables...', + done: 'Environment variables updated' + }, + { + id: STEP_IDS.DOCKER_CONTAINERS, + inProgress: 'Restarting Docker containers...', + done: 'Docker containers restarted' + } + ] : [ + { + id: STEP_IDS.CONFIG_FILES, + inProgress: 'Creating configuration files...', + done: 'Configuration files created' + }, + { + id: STEP_IDS.DOCKER_COMPOSE, + inProgress: 'Generating Docker Compose file...', + done: 'Docker Compose file generated' + }, + { + id: STEP_IDS.ENV_VARS, + inProgress: 'Configuring environment variables...', + done: 'Environment variables configured' + }, + { + id: STEP_IDS.DOCKER_CONTAINERS, + inProgress: 'Starting Docker containers...', + done: 'Docker containers started' + } + ]); + + const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode()); + const CONSTANTS = window.InstallerConstants || {}; + const TIMINGS = { + errorClear: CONSTANTS.errorClearMs ?? 180, + installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000, + installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, + redirectDelay: CONSTANTS.redirectDelayMs ?? 500, + mockStepDelay: CONSTANTS.mockStepDelayMs ?? 1800, + progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140, + progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120 + }; + + const clampStep = (step) => { + const numeric = Number(step); + if (Number.isNaN(numeric)) return 1; + return Math.max(1, Math.min(4, numeric)); + }; + + window.InstallerStepsContext = Object.freeze({ + getBodyDataset, + isUpgradeMode, + getLockedDatabase, + isMockMode, + isMockErrorMode, + isMockToastMode, + isMockProgressMode, + STEP_IDS, + STATUS, + SSE_EVENTS, + INSTALLATION_STEPS, + TIMINGS, + clampStep + }); +})(); diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js new file mode 100644 index 0000000000..8c8acce04c --- /dev/null +++ b/app/views/install/installer/js/modules/progress.js @@ -0,0 +1,909 @@ +(() => { + const { + INSTALLATION_STEPS, + TIMINGS, + isMockMode, + isMockErrorMode, + isMockProgressMode, + isMockToastMode, + getBodyDataset, + STEP_IDS, + STATUS, + SSE_EVENTS + } = window.InstallerStepsContext; + const { + formState, + applyLockPayload, + applyBodyDefaults, + setInstallLock, + getInstallLock, + clearInstallLock, + isInstallLocked, + syncInstallLockFlag, + getStoredInstallId, + storeInstallId, + clearInstallId + } = window.InstallerStepsState || {}; + const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {}; + const { generateSecretKey } = window.InstallerStepsUI || {}; + const { showToast } = window.InstallerToast || {}; + + let activeInstall = null; + let unloadGuard = null; + const csrfToken = document.querySelector('meta[name="appwrite-installer-csrf"]')?.getAttribute('content') || ''; + + const withCsrfHeader = (headers = {}) => { + if (!csrfToken) { + return headers; + } + return { ...headers, 'X-Appwrite-Installer-CSRF': csrfToken }; + }; + + const showCsrfToast = () => { + showToast?.({ + status: 'error', + title: 'Session expired', + description: 'Refresh the page and try again.', + dismissible: true + }); + }; + + const validateInstallRequest = async () => { + if (isMockProgressMode?.()) return true; + if (isMockToastMode?.()) { + showCsrfToast(); + return false; + } + try { + const response = await fetch('/install/validate', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }) + }); + if (!response.ok) { + showCsrfToast(); + return false; + } + const data = await response.json().catch(() => ({})); + if (!data?.success) { + showCsrfToast(); + return false; + } + return true; + } catch (error) { + showCsrfToast(); + return false; + } + }; + + const setUnloadGuard = (enabled) => { + if (!enabled && unloadGuard) { + window.removeEventListener('beforeunload', unloadGuard); + unloadGuard = null; + return; + } + + if (enabled && !unloadGuard) { + unloadGuard = (event) => { + event.preventDefault(); + event.returnValue = ''; + return ''; + }; + window.addEventListener('beforeunload', unloadGuard); + } + }; + + const cleanupInstallFlow = () => { + if (activeInstall?.controller) { + activeInstall.controller.abort(); + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + } + setUnloadGuard(false); + }; + + const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id); + + const getProgressLabel = (step, status, message) => { + if (!step) return message || ''; + if (status === STATUS.ERROR) { + const normalized = normalizeInstallError(message || ''); + return normalized.summary || 'Installation failed.'; + } + if (status === STATUS.COMPLETED) return step.done; + return step.inProgress; + }; + + const updateInstallRow = (row, step, status, message) => { + if (!row || !step) return; + row.dataset.status = status; + row.dataset.step = step.id; + if (status !== STATUS.ERROR) { + row.classList.remove('is-open'); + const toggle = row.querySelector('[data-install-toggle]'); + if (toggle) { + toggle.setAttribute('aria-expanded', 'false'); + } + } + const label = getProgressLabel(step, status, message); + const text = row.querySelector('[data-install-text]'); + if (text) { + text.textContent = label; + } + }; + + const normalizeInstallError = (message) => { + const text = String(message || '').trim(); + if (!text) { + return { summary: '', details: '' }; + } + const colonIndex = text.indexOf(':'); + if (colonIndex > 0 && colonIndex < 80) { + const summary = text.slice(0, colonIndex).trim(); + const details = text.slice(colonIndex + 1).trim(); + return { summary, details }; + } + if (text.length > 180) { + return { summary: text.slice(0, 180).trim() + '…', details: text }; + } + return { summary: text, details: '' }; + }; + + const updateInstallErrorDetails = (row, error) => { + if (!row) return; + const traceNode = row.querySelector('[data-install-trace]'); + const normalized = normalizeInstallError(error?.message || ''); + const output = error?.output || ''; + const trace = error?.trace || ''; + const detailChunks = []; + if (normalized.details) detailChunks.push(normalized.details); + if (output) detailChunks.push(output); + if (trace) detailChunks.push(trace); + const detailText = detailChunks.join('\n\n'); + + if (traceNode) { + traceNode.textContent = detailText; + traceNode.style.display = detailText ? 'block' : 'none'; + } + }; + + const createInstallRow = (template, step) => { + const fragment = template.content.cloneNode(true); + const row = fragment.querySelector('.install-row'); + if (!row) return null; + const toggle = row.querySelector('[data-install-toggle]'); + const setOpenState = (isOpen) => { + row.classList.toggle('is-open', isOpen); + if (toggle) { + toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + } + }; + const toggleRow = () => { + if (!row.dataset.status || row.dataset.status !== STATUS?.ERROR) { + return; + } + setOpenState(!row.classList.contains('is-open')); + }; + row.addEventListener('click', (event) => { + if (event.target.closest('[data-install-retry]')) { + return; + } + if (event.target.closest('[data-install-toggle]')) { + return; + } + if (event.target.closest('.install-row-details')) { + return; + } + toggleRow(); + }); + if (toggle) { + toggle.addEventListener('click', (event) => { + event.stopPropagation(); + toggleRow(); + }); + } + updateInstallRow(row, step, STATUS.IN_PROGRESS); + return row; + }; + + const generateInstallId = () => { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const buildRedirectUrl = () => { + const dataset = getBodyDataset?.() ?? {}; + const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim(); + if (!rawDomain) return ''; + const httpPort = (formState?.httpPort || dataset.defaultHttpPort || '').trim(); + const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim(); + 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?.(normalizedHost)) { + protocol = 'https'; + port = httpsPort; + } + if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) { + host = `${rawDomain}:${port}`; + } + return `${protocol}://${host}`; + }; + + const redirectToApp = () => { + if (isMockMode?.()) return; + const url = buildRedirectUrl(); + if (!url) return; + window.location.href = url; + }; + + const notifyInstallComplete = (installId) => { + if (isMockProgressMode?.()) return Promise.resolve(); + if (!installId) return Promise.resolve(); + return fetch('/install/complete', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify({ installId }) + }).catch(() => {}); + }; + + const buildInstallPayload = (installId) => { + const normalizedSecret = (formState?.opensslKey || '').trim(); + if (!normalizedSecret && generateSecretKey) { + formState.opensslKey = generateSecretKey(); + } + const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost'; + const normalizedHttpPort = (formState?.httpPort || '').trim() || '80'; + const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443'; + const normalizedEmail = (formState?.emailCertificates || '').trim(); + const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim(); + + return { + installId, + httpPort: normalizedHttpPort, + httpsPort: normalizedHttpsPort, + database: formState?.database || 'mongodb', + appDomain: normalizedDomain, + emailCertificates: normalizedEmail, + opensslKey: (formState?.opensslKey || '').trim(), + assistantOpenAIKey: normalizedAssistantKey + }; + }; + + const fetchInstallStatus = async (installId) => { + if (isMockProgressMode?.()) return null; + if (!installId) return null; + const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, { + cache: 'no-store' + }); + if (!response.ok) return null; + const json = await response.json(); + return json.progress || null; + }; + + const readEventStream = async (stream, onEvent) => { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + const processEvent = (rawEvent) => { + if (!rawEvent) return; + const lines = rawEvent.split('\n'); + let eventName = 'message'; + let data = ''; + + lines.forEach((line) => { + if (line.startsWith('event:')) { + eventName = line.replace('event:', '').trim(); + } else if (line.startsWith('data:')) { + data += line.replace('data:', '').trim(); + } + }); + + if (data) { + try { + const parsed = JSON.parse(data); + onEvent(eventName, parsed); + } catch (error) { + onEvent(eventName, { message: data }); + } + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + buffer = buffer.replace(/\r\n/g, '\n'); + if (buffer.trim()) { + processEvent(buffer); + } + break; + } + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, '\n'); + let separatorIndex = buffer.indexOf('\n\n'); + + while (separatorIndex !== -1) { + const rawEvent = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + processEvent(rawEvent); + separatorIndex = buffer.indexOf('\n\n'); + } + } + } finally { + try { + reader.releaseLock(); + } catch (error) {} + } + }; + + const initStep4 = (root) => { + if (!root) return; + if (isMockProgressMode?.()) { + clearInstallLock?.(); + clearInstallId?.(); + } + + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + + const list = root.querySelector('[data-install-list]'); + const template = root.querySelector('#install-row-template'); + if (!list || !template) return; + + list.innerHTML = ''; + const rowsById = new Map(); + const progressState = new Map(); + let pendingProgressTimer = null; + let pendingProgressStep = null; + let pendingCompletionTimer = null; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + + const ensureRow = (step) => { + if (!step) return null; + if (rowsById.has(step.id)) { + return rowsById.get(step.id); + } + const row = createInstallRow(template, step); + if (!row) return null; + row.classList.add('is-entering'); + list.appendChild(row); + row.getBoundingClientRect(); + requestAnimationFrame(() => { + row.classList.remove('is-entering'); + }); + rowsById.set(step.id, row); + return row; + }; + + const installPanel = root.querySelector('.install-panel'); + let panelHeightCleanup = null; + const animatePanelHeight = (mutate) => { + if (!installPanel) { + mutate(); + return; + } + if (panelHeightCleanup) { + panelHeightCleanup(); + panelHeightCleanup = null; + } + const currentHeight = installPanel.getBoundingClientRect().height; + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + mutate(); + const nextHeight = installPanel.getBoundingClientRect().height; + if (currentHeight === nextHeight) { + installPanel.style.height = ''; + return; + } + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + installPanel.style.height = `${nextHeight}px`; + const cleanup = () => { + installPanel.style.height = ''; + installPanel.removeEventListener('transitionend', onEnd); + }; + const onEnd = (event) => { + if (event.propertyName === 'height') { + cleanup(); + } + }; + panelHeightCleanup = cleanup; + installPanel.addEventListener('transitionend', onEnd); + }; + + const renderProgress = () => { + animatePanelHeight(() => { + const visibleSteps = []; + for (const step of INSTALLATION_STEPS) { + const state = progressState.get(step.id); + if (!state) break; + visibleSteps.push(step); + } + + visibleSteps.forEach((step) => { + const state = progressState.get(step.id); + if (!state) return; + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message); + if (state.status === STATUS?.ERROR) { + updateInstallErrorDetails(row, { + message: state.message, + trace: state.details?.trace, + output: state.details?.output + }); + } + } + }); + }); + }; + + const firstStep = INSTALLATION_STEPS[0]; + if (firstStep) { + progressState.set(firstStep.id, { + status: STATUS.IN_PROGRESS, + message: firstStep.inProgress + }); + } + renderProgress(); + + const applyProgress = (payload) => { + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + progressState.set(step.id, { + status: payload.status || STATUS.IN_PROGRESS, + message: payload.message, + details: payload.details + }); + renderProgress(); + if (activeInstall) { + activeInstall.lastEventAt = Date.now(); + if (payload.status === STATUS.ERROR) { + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + activeInstall.pollTimer = null; + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + activeInstall.fallbackTimer = null; + } + } + } + scheduleFallback(); + }; + + const handleProgress = (payload) => { + if (!payload || !payload.step) return; + + const existingState = progressState.get(payload.step); + if (existingState && existingState.status === STATUS.COMPLETED && payload.status === STATUS.IN_PROGRESS) { + return; + } + + if (pendingProgressTimer && pendingProgressStep === payload.step && payload.status !== STATUS.IN_PROGRESS) { + clearTimeout(pendingProgressTimer); + pendingProgressTimer = null; + pendingProgressStep = null; + } + if (pendingCompletionTimer) { + clearTimeout(pendingCompletionTimer); + pendingCompletionTimer = null; + } + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + if (payload.status === STATUS.IN_PROGRESS) { + const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id); + 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 !== STATUS.COMPLETED) { + completionTargets.push({ + step: previousStep, + details: previousState.details + }); + } + } + } + if (completionTargets.length && currentIndex > 0) { + pendingProgressStep = payload.step; + pendingCompletionTimer = setTimeout(() => { + pendingCompletionTimer = null; + completionTargets.forEach(({ step: previousStep, details }) => { + progressState.set(previousStep.id, { + status: STATUS.COMPLETED, + message: previousStep.done, + details + }); + }); + renderProgress(); + }, TIMINGS?.progressCompleteDelay ?? 0); + pendingProgressTimer = setTimeout(() => { + pendingProgressTimer = null; + pendingProgressStep = null; + applyProgress(payload); + }, TIMINGS?.progressTransitionDelay ?? 0); + scheduleFallback(); + return; + } + } + applyProgress(payload); + }; + + const applySnapshot = (snapshot) => { + if (!snapshot || !snapshot.steps) return; + INSTALLATION_STEPS.forEach((step) => { + const detail = snapshot.steps[step.id]; + if (!detail) return; + progressState.set(step.id, { + status: detail.status, + message: detail.message, + details: snapshot.details?.[step.id] + }); + }); + renderProgress(); + }; + + const startPolling = () => { + if (isMockProgressMode?.()) return; + if (!activeInstall || activeInstall.pollTimer) return; + activeInstall.pollTimer = setInterval(async () => { + if (!activeInstall || activeInstall.completed) return; + const snapshot = await fetchInstallStatus(activeInstall.installId); + if (snapshot) { + applySnapshot(snapshot); + } + }, TIMINGS?.installPollInterval ?? 0); + }; + + const scheduleFallback = () => { + if (isMockProgressMode?.()) return; + if (!activeInstall) return; + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall.fallbackTimer = setTimeout(() => { + if (!activeInstall) return; + startPolling(); + }, TIMINGS?.installFallbackDelay ?? 0); + }; + + const finalizeInstall = () => { + if (!activeInstall) return; + activeInstall.completed = true; + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + setUnloadGuard(false); + }; + + const startInstallStream = async (installId, options = {}) => { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + + const payload = buildInstallPayload(installId); + if (options.retryStep) { + payload.retryStep = options.retryStep; + } + if (!isMockProgressMode?.()) { + setInstallLock?.(installId, payload); + setUnloadGuard(true); + } + + try { + if (options.forceMock || isMockProgressMode?.()) { + simulateInstallProgress(options); + return; + } + scheduleFallback(); + const response = await fetch('/install', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }), + body: JSON.stringify(payload), + signal: activeInstall.controller.signal + }); + + if (!response.ok || !response.body) { + let errorMessage = null; + try { + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + const data = await response.json(); + errorMessage = data?.message || null; + } + } catch (error) { + errorMessage = null; + } + if (errorMessage) { + handleProgress({ + step: STEP_IDS.CONFIG_FILES, + status: STATUS.ERROR, + message: errorMessage + }); + finalizeInstall(); + return; + } + startPolling(); + return; + } + + await readEventStream(response.body, (event, data) => { + if (!activeInstall) return; + if (event === SSE_EVENTS.INSTALL_ID && data?.installId) { + activeInstall.installId = data.installId; + storeInstallId?.(data.installId); + return; + } + if (event === SSE_EVENTS.PROGRESS) { + handleProgress(data); + return; + } + if (event === SSE_EVENTS.DONE) { + const lastStep = INSTALLATION_STEPS[INSTALLATION_STEPS.length - 1]; + if (lastStep) { + const lastState = progressState.get(lastStep.id); + if (!lastState || lastState.status !== STATUS.COMPLETED) { + progressState.set(lastStep.id, { + status: STATUS.COMPLETED, + message: lastStep.done, + details: lastState?.details + }); + renderProgress(); + } + } + finalizeInstall(); + notifyInstallComplete(activeInstall?.installId); + setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); + return; + } + if (event === SSE_EVENTS.ERROR) { + if (data?.message) { + const existingError = Array.from(progressState.values()).some((state) => state?.status === STATUS.ERROR); + if (data.step || !existingError) { + let targetStep = data.step; + if (!targetStep) { + for (const candidate of INSTALLATION_STEPS) { + const state = progressState.get(candidate.id); + if (!state || state.status !== STATUS.COMPLETED) { + targetStep = candidate.id; + break; + } + } + } + handleProgress({ + step: targetStep || STEP_IDS.CONFIG_FILES, + status: STATUS.ERROR, + message: data.message, + details: data.details + }); + } + } + finalizeInstall(); + } + }); + if (activeInstall && !activeInstall.completed) { + startPolling(); + } + } catch (error) { + if (!activeInstall || activeInstall.controller.signal.aborted) { + return; + } + startPolling(); + } + }; + + const resumeInstall = async (installId) => { + const snapshot = await fetchInstallStatus(installId); + if (!snapshot) return false; + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + applySnapshot(snapshot); + startPolling(); + setUnloadGuard(true); + return true; + }; + + const resetProgressFrom = (stepId) => { + const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId); + if (index === -1) return; + INSTALLATION_STEPS.slice(index).forEach((step) => { + progressState.delete(step.id); + const row = rowsById.get(step.id); + if (row && row.parentNode) { + row.parentNode.removeChild(row); + } + rowsById.delete(step.id); + }); + }; + + const retryInstallStep = (stepId) => { + if (!stepId) return; + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + + resetProgressFrom(stepId); + + const step = getStepDefinition(stepId); + progressState.set(stepId, { + status: STATUS.IN_PROGRESS, + message: step?.inProgress || 'Retrying...' + }); + + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, STATUS.IN_PROGRESS, step.inProgress || 'Retrying...'); + } + + const installId = activeInstall?.installId || getInstallLock?.()?.installId || generateInstallId(); + storeInstallId?.(installId); + startInstallStream(installId, { retryStep: stepId, forceMock: isMockProgressMode?.() }); + }; + + list.addEventListener('click', (event) => { + const button = event.target.closest('[data-install-retry]'); + if (!button) return; + const row = button.closest('.install-row'); + const stepId = row?.dataset.step; + retryInstallStep(stepId); + }); + + const simulateInstallProgress = (options = {}) => { + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + const startIndex = options.retryStep + ? Math.max(0, INSTALLATION_STEPS.findIndex((step) => step.id === options.retryStep)) + : 0; + let index = startIndex; + const errorStepId = isMockErrorMode?.() && !options.retryStep + ? STEP_IDS.DOCKER_CONTAINERS + : null; + const mockErrorDetails = (window.InstallerConstants || {}).mockErrorDetails || { + output: 'Error response from daemon: manifest for appwrite/appwrite:local not found: manifest unknown', + trace: '#0 /usr/src/code/src/Appwrite/Platform/Tasks/Install.php(540): Appwrite\\\\Platform\\\\Tasks\\\\Install->performInstallation(...)\\n#1 {main}' + }; + const advance = () => { + if (index > 0) { + const previous = INSTALLATION_STEPS[index - 1]; + const previousState = progressState.get(previous.id); + if (!previousState || previousState.status !== STATUS.COMPLETED) { + handleProgress({ + step: previous.id, + status: STATUS.COMPLETED, + message: previous.done + }); + } + } + + if (index >= INSTALLATION_STEPS.length) { + finalizeInstall(); + return; + } + + const step = INSTALLATION_STEPS[index]; + handleProgress({ + step: step.id, + status: STATUS.IN_PROGRESS, + message: step.inProgress + }); + + if (errorStepId && step.id === errorStepId) { + index += 1; + activeInstall.pollTimer = setTimeout(() => { + handleProgress({ + step: step.id, + status: STATUS.ERROR, + message: 'Failed to start containers', + details: mockErrorDetails + }); + finalizeInstall(); + }, TIMINGS?.mockStepDelay ?? 0); + return; + } + + index += 1; + activeInstall.pollTimer = setTimeout(advance, TIMINGS?.mockStepDelay ?? 0); + }; + advance(); + }; + + if (isMockProgressMode?.()) { + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + return; + } + + const lock = getInstallLock?.(); + const existingInstallId = lock?.installId || getStoredInstallId?.(); + if (existingInstallId) { + resumeInstall(existingInstallId).then((resumed) => { + if (!resumed) { + clearInstallId?.(); + clearInstallLock?.(); + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + } + }); + } else { + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + } + }; + + window.InstallerStepsProgress = { + initStep4, + cleanupInstallFlow, + validateInstallRequest + }; +})(); diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js new file mode 100644 index 0000000000..c51b01aec2 --- /dev/null +++ b/app/views/install/installer/js/modules/state.js @@ -0,0 +1,153 @@ +(() => { + const { + getBodyDataset, + isUpgradeMode, + getLockedDatabase, + isMockProgressMode + } = window.InstallerStepsContext || {}; + + const INSTALL_LOCK_KEY = 'appwrite-install-lock'; + const INSTALL_ID_KEY = 'appwrite-install-id'; + + const formState = { + appDomain: null, + database: null, + httpPort: null, + httpsPort: null, + emailCertificates: null, + opensslKey: null, + assistantOpenAIKey: null + }; + + const dispatchStateChange = (key) => { + if (!key || typeof document === 'undefined') return; + try { + document.dispatchEvent(new CustomEvent('installer:state-change', { + detail: { key, value: formState[key] } + })); + } catch (error) {} + }; + + const setStateIfEmpty = (key, value) => { + if (value === null || value === undefined || value === '') return; + if (formState[key] === null || formState[key] === undefined || formState[key] === '') { + formState[key] = value; + } + }; + + const applyBodyDefaults = () => { + const data = getBodyDataset?.() ?? {}; + setStateIfEmpty('appDomain', data.defaultAppDomain); + setStateIfEmpty('httpPort', data.defaultHttpPort); + setStateIfEmpty('httpsPort', data.defaultHttpsPort); + setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); + setStateIfEmpty('opensslKey', data.defaultSecretKey); + setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); + if (data.lockedDatabase) { + formState.database = data.lockedDatabase; + } + if (!isUpgradeMode?.()) { + setStateIfEmpty('database', data.defaultDatabase); + } + }; + + const getInstallLock = () => { + try { + const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch (error) { + return null; + } + }; + + const setInstallLock = (installId, payload) => { + const lock = { + installId, + payload: payload || null, + startedAt: Date.now() + }; + try { + sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); + } catch (error) {} + if (document.body) { + document.body.dataset.installLocked = 'true'; + } + return lock; + }; + + const clearInstallLock = () => { + try { + sessionStorage.removeItem(INSTALL_LOCK_KEY); + } catch (error) {} + if (document.body) { + delete document.body.dataset.installLocked; + } + }; + + const isInstallLocked = () => { + if (isMockProgressMode?.()) return false; + return Boolean(getInstallLock()); + }; + + const syncInstallLockFlag = () => { + if (!document.body) return; + if (isInstallLocked()) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const applyLockPayload = () => { + const lock = getInstallLock(); + if (!lock || !lock.payload) return; + const payload = lock.payload; + setStateIfEmpty('appDomain', payload.appDomain); + setStateIfEmpty('database', payload.database); + setStateIfEmpty('httpPort', payload.httpPort); + setStateIfEmpty('httpsPort', payload.httpsPort); + setStateIfEmpty('emailCertificates', payload.emailCertificates); + setStateIfEmpty('opensslKey', payload.opensslKey); + setStateIfEmpty('assistantOpenAIKey', payload.assistantOpenAIKey); + }; + + const getStoredInstallId = () => { + try { + return sessionStorage.getItem(INSTALL_ID_KEY); + } catch (error) { + return null; + } + }; + + const storeInstallId = (installId) => { + try { + sessionStorage.setItem(INSTALL_ID_KEY, installId); + } catch (error) {} + }; + + const clearInstallId = () => { + try { + sessionStorage.removeItem(INSTALL_ID_KEY); + } catch (error) {} + }; + + window.InstallerStepsState = { + formState, + dispatchStateChange, + setStateIfEmpty, + applyBodyDefaults, + applyLockPayload, + getInstallLock, + setInstallLock, + clearInstallLock, + isInstallLocked, + syncInstallLockFlag, + getStoredInstallId, + storeInstallId, + clearInstallId, + getLockedDatabase: getLockedDatabase || (() => '') + }; +})(); diff --git a/app/views/install/installer/js/modules/toast.js b/app/views/install/installer/js/modules/toast.js new file mode 100644 index 0000000000..5a8eb55f41 --- /dev/null +++ b/app/views/install/installer/js/modules/toast.js @@ -0,0 +1,95 @@ +(() => { + const TOAST_STACK_ID = 'installer-toast-stack'; + const DEFAULT_TIMEOUT = 5000; + const MAX_TOASTS = 3; + const ICONS = { + error: '', + close: '' + }; + + const getStack = () => document.getElementById(TOAST_STACK_ID); + + const dismissToast = (toast) => { + if (!toast) return; + if (toast.classList.contains('is-leaving')) return; + toast.classList.add('is-leaving'); + const remove = () => toast.remove(); + toast.addEventListener('transitionend', remove, { once: true }); + setTimeout(remove, 450); + }; + + const showToast = ({ + title = '', + description = '', + status = 'error', + dismissible = true, + timeout = DEFAULT_TIMEOUT + } = {}) => { + const stack = getStack(); + if (!stack) return; + const visibleToasts = Array.from( + stack.querySelectorAll('.installer-toast:not(.is-leaving)') + ); + if (visibleToasts.length >= MAX_TOASTS) { + dismissToast(visibleToasts[0]); + } + + const toast = document.createElement('div'); + toast.className = 'installer-toast is-entering'; + toast.dataset.status = status; + toast.setAttribute('role', status === 'error' ? 'alert' : 'status'); + + const content = document.createElement('div'); + content.className = 'installer-toast-content'; + + const icon = document.createElement('span'); + icon.className = 'installer-toast-icon'; + icon.dataset.status = status; + icon.innerHTML = ICONS.error; + content.appendChild(icon); + + const body = document.createElement('section'); + body.className = 'installer-toast-body'; + + if (title) { + const titleNode = document.createElement('p'); + titleNode.className = 'installer-toast-title typography-text-m-500'; + titleNode.textContent = title; + body.appendChild(titleNode); + } + + if (description) { + const descNode = document.createElement('p'); + descNode.className = 'installer-toast-description typography-text-m-400'; + descNode.textContent = description; + body.appendChild(descNode); + } + + content.appendChild(body); + toast.appendChild(content); + + if (dismissible) { + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'installer-toast-close'; + close.setAttribute('aria-label', 'Dismiss notification'); + close.innerHTML = ICONS.close; + close.addEventListener('click', () => dismissToast(toast)); + toast.appendChild(close); + } + + stack.appendChild(toast); + toast.getBoundingClientRect(); + requestAnimationFrame(() => { + toast.classList.remove('is-entering'); + }); + + if (timeout > 0) { + setTimeout(() => dismissToast(toast), timeout); + } + }; + + window.InstallerToast = Object.freeze({ + showToast + }); +})(); diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js new file mode 100644 index 0000000000..9db7488efb --- /dev/null +++ b/app/views/install/installer/js/modules/ui.js @@ -0,0 +1,268 @@ +(() => { + const { TIMINGS } = window.InstallerStepsContext || {}; + const { formState } = window.InstallerStepsState || {}; + + const clearFieldErrors = (root) => { + if (!root) return; + root.querySelectorAll('.field-error').forEach((node) => node.remove()); + root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => { + node.classList.remove('is-error'); + }); + }; + + const setFieldError = (input, message) => { + if (!input) return; + const group = input.closest('.input-group'); + if (!group) return; + let error = group.querySelector('.field-error'); + let errorText = error?.querySelector('.field-error-text'); + const hasSameMessage = Boolean(errorText && errorText.textContent === message); + const alreadyVisible = Boolean(error && error.classList.contains('is-visible')); + if (!error) { + const template = document.getElementById('field-error-template'); + if (template && template.content) { + const fragment = template.content.cloneNode(true); + error = fragment.querySelector('.field-error'); + group.appendChild(fragment); + } else { + error = document.createElement('div'); + error.className = 'field-error typography-text-xs-400 text-error'; + const text = document.createElement('span'); + text.className = 'field-error-text'; + error.appendChild(text); + group.appendChild(error); + } + errorText = error.querySelector('.field-error-text'); + } + if (errorText) { + errorText.textContent = message; + } + if (!hasSameMessage || !alreadyVisible) { + requestAnimationFrame(() => { + error.classList.add('is-visible'); + }); + } + input.classList.add('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.add('is-error'); + } + }; + + const bindErrorClear = (input) => { + if (!input) return; + const handler = () => { + const group = input.closest('.input-group'); + const error = group?.querySelector('.field-error'); + if (error) { + error.classList.remove('is-visible'); + setTimeout(() => { + error.remove(); + }, TIMINGS?.errorClear ?? 0); + } + input.classList.remove('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.remove('is-error'); + } + }; + input.addEventListener('input', handler); + input.addEventListener('change', handler); + }; + + const toDatabaseLabel = (value) => { + if (!value) return ''; + return value.toLowerCase() === 'mariadb' ? 'MariaDB' : 'MongoDB'; + }; + + const updateDatabaseSelection = (radio, root) => { + if (!radio || !root) return; + const allOptions = root.querySelectorAll('.selector-card'); + allOptions.forEach((option) => option.classList.remove('selected')); + const selectedOption = radio.closest('.selector-card'); + if (selectedOption) { + selectedOption.classList.add('selected'); + } + }; + + const syncResetButton = (input, button) => { + const defaultValue = input.dataset.default ?? ''; + button.disabled = input.value === defaultValue; + }; + + const setupResetButtons = (root) => { + const inputs = root.querySelectorAll('.input-field[data-default]'); + inputs.forEach((input) => { + const button = root.querySelector(`[data-reset-target="${input.id}"]`); + if (!button) return; + + syncResetButton(input, button); + + input.addEventListener('input', () => syncResetButton(input, button)); + button.addEventListener('click', () => { + input.value = input.dataset.default ?? ''; + syncResetButton(input, button); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }); + }; + + const toggleAccordion = (button) => { + const content = button.nextElementSibling; + const icon = button.querySelector('.accordion-chevron'); + const isOpen = button.classList.contains('is-open'); + + button.classList.toggle('is-open', !isOpen); + button.setAttribute('aria-expanded', String(!isOpen)); + + if (content) { + if (!isOpen) { + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } else { + content.style.maxHeight = '0px'; + content.classList.remove('open'); + } + } + + if (icon) { + icon.setAttribute('data-open', String(!isOpen)); + } + }; + + const setupAccordion = (root) => { + const buttons = root.querySelectorAll('.accordion-toggle'); + buttons.forEach((button) => { + button.addEventListener('click', () => toggleAccordion(button)); + }); + }; + + const openAccordion = (root) => { + const toggle = root.querySelector('.accordion-toggle'); + const content = root.querySelector('.accordion-content'); + if (!toggle || !content) return; + if (!toggle.classList.contains('is-open')) { + toggle.classList.add('is-open'); + toggle.setAttribute('aria-expanded', 'true'); + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } + }; + + const disableControls = (root) => { + const inputs = root.querySelectorAll('input, select, textarea'); + inputs.forEach((input) => { + if (input.type === 'radio' || input.type === 'checkbox') { + input.disabled = true; + } else { + input.readOnly = true; + input.setAttribute('aria-disabled', 'true'); + } + }); + + const buttons = root.querySelectorAll('button'); + buttons.forEach((button) => { + if (button.matches('[data-copy-target]')) return; + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + }); + + root.classList.add('is-locked'); + }; + + const generateSecretKey = () => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const copyToClipboard = (value, input) => { + if (!value) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value); + return; + } + if (input) { + input.select(); + document.execCommand('copy'); + input.setSelectionRange(0, 0); + return; + } + const textArea = document.createElement('textarea'); + textArea.value = value; + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + } catch (error) {} finally { + document.body.removeChild(textArea); + } + }; + + const setTooltipText = (wrapper, message) => { + if (!wrapper) return; + const tooltip = wrapper.querySelector('.tooltip'); + if (tooltip && message) { + tooltip.textContent = message; + } + }; + + const resetTooltipText = (wrapper) => { + if (!wrapper) return; + const defaultText = wrapper.dataset.tooltipDefault; + if (!defaultText) return; + setTooltipText(wrapper, defaultText); + }; + + const updateReviewSummary = (root) => { + if (!root) return; + const valueNodes = root.querySelectorAll('[data-review-value]'); + valueNodes.forEach((node) => { + const key = node.dataset.reviewValue; + if (!key) return; + let value = formState?.[key]; + if (key === 'database') { + value = toDatabaseLabel(formState?.database); + } + if (value) { + node.textContent = value; + } + }); + + const badge = root.querySelector('[data-review-badge]'); + if (badge) { + const hasKey = Boolean((formState?.opensslKey || '').trim()); + badge.textContent = hasKey ? 'Generated' : 'Missing'; + badge.classList.remove('badge-success', 'badge-warning'); + badge.classList.add(hasKey ? 'badge-success' : 'badge-warning'); + } + + const assistantBadge = root.querySelector('[data-review-assistant-badge]'); + if (assistantBadge) { + const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim()); + assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled'; + assistantBadge.classList.remove('badge-success', 'badge-neutral'); + assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral'); + } + }; + + window.InstallerStepsUI = { + clearFieldErrors, + setFieldError, + bindErrorClear, + toDatabaseLabel, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + }; +})(); diff --git a/app/views/install/installer/js/modules/validation.js b/app/views/install/installer/js/modules/validation.js new file mode 100644 index 0000000000..78a6d7e20c --- /dev/null +++ b/app/views/install/installer/js/modules/validation.js @@ -0,0 +1,111 @@ +(() => { + const isValidEmail = (email) => { + if (!email) return false; + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); + }; + + const isValidPort = (value) => { + const numeric = Number(value); + if (!Number.isInteger(numeric)) return false; + return numeric >= 1 && numeric <= 65535; + }; + + const isValidIPv4 = (host) => { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false; + return host.split('.').every((part) => { + const num = Number(part); + return num >= 0 && num <= 255; + }); + }; + + const isValidIPv6 = (host) => { + try { + const url = new URL(`http://[${host}]`); + return url.hostname.toLowerCase() === host.toLowerCase(); + } catch (error) { + return false; + } + }; + + const isValidHostnameLabel = (label) => { + if (!label || label.length > 63) return false; + if (label.startsWith('-') || label.endsWith('-')) return false; + return /^[a-zA-Z0-9-]+$/.test(label); + }; + + const isValidDomain = (host) => { + if (host.length > 253) return false; + const labels = host.split('.'); + return labels.every((label) => isValidHostnameLabel(label)); + }; + + const isValidHost = (host) => { + if (host === 'localhost') return true; + if (isValidIPv4(host)) return true; + if (isValidIPv6(host)) return true; + return isValidDomain(host); + }; + + const isValidHostnameInput = (value) => { + if (!value) return false; + const trimmed = value.trim(); + if (!trimmed) return false; + + let host = trimmed; + let port = null; + + if (trimmed.startsWith('[')) { + const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (!match) return false; + host = match[1] || ''; + port = match[2] || null; + } else { + const parts = trimmed.split(':'); + if (parts.length > 2) return false; + if (parts.length === 2) { + host = parts[0]; + port = parts[1]; + } + } + + if (port !== null && port !== '' && !isValidPort(port)) { + return false; + } + + return isValidHost(host); + }; + + const extractHostname = (value) => { + if (!value) return ''; + const trimmed = value.trim(); + if (trimmed.startsWith('[')) { + const end = trimmed.indexOf(']'); + if (end !== -1) { + return trimmed.slice(1, end); + } + return trimmed; + } + const colonCount = (trimmed.match(/:/g) || []).length; + if (colonCount === 1) { + return trimmed.split(':')[0]; + } + return trimmed; + }; + + const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); + + const isLocalHost = (host) => { + if (!host) return false; + const normalized = host.toLowerCase(); + return LOCAL_HOSTS.has(normalized); + }; + + window.InstallerStepsValidation = { + isValidEmail, + isValidPort, + isValidHostnameInput, + extractHostname, + isLocalHost + }; +})(); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 9fdd06fd08..1677812596 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -1,428 +1,65 @@ (() => { - const INSTALL_LOCK_KEY = 'appwrite-install-lock'; - const INSTALL_ID_KEY = 'appwrite-install-id'; - const isMockMode = () => document.body?.dataset.installMode === 'mock' - || (typeof navigator !== 'undefined' && navigator.webdriver); - const getBodyDataset = () => document.body?.dataset ?? {}; - const isUpgradeMode = () => getBodyDataset().upgrade === 'true'; - const getLockedDatabase = () => getBodyDataset().lockedDatabase || ''; - const isMockErrorMode = () => { - if (typeof window === 'undefined') return false; - const params = new URLSearchParams(window.location.search); - return params.get('mock-error') === '1'; - }; - const isMockProgressMode = () => isMockMode() || isMockErrorMode(); + const Context = window.InstallerStepsContext || {}; + const State = window.InstallerStepsState || {}; + const Validation = window.InstallerStepsValidation || {}; + const UI = window.InstallerStepsUI || {}; + const Progress = window.InstallerStepsProgress || {}; + const Tooltips = window.InstallerTooltips || null; - const buildInstallationSteps = (upgrade) => (upgrade ? [ - { - id: 'config-files', - inProgress: 'Updating configuration files...', - done: 'Configuration files updated' - }, - { - id: 'docker-compose', - inProgress: 'Updating Docker Compose file...', - done: 'Docker Compose file updated' - }, - { - id: 'env-vars', - inProgress: 'Updating environment variables...', - done: 'Environment variables updated' - }, - { - id: 'docker-containers', - inProgress: 'Restarting Docker containers...', - done: 'Docker containers restarted' - } - ] : [ - { - id: 'config-files', - inProgress: 'Creating configuration files...', - done: 'Configuration files created' - }, - { - id: 'docker-compose', - inProgress: 'Generating Docker Compose file...', - done: 'Docker Compose file generated' - }, - { - id: 'env-vars', - inProgress: 'Configuring environment variables...', - done: 'Environment variables configured' - }, - { - id: 'docker-containers', - inProgress: 'Starting Docker containers...', - done: 'Docker containers started' - } - ]); + const { + INSTALLATION_STEPS, + clampStep, + isMockMode + } = Context; - const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode()); - const CONSTANTS = window.InstallerConstants || {}; - const TIMINGS = { - errorClear: CONSTANTS.errorClearMs ?? 180, - installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000, - installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, - redirectDelay: CONSTANTS.redirectDelayMs ?? 500, - mockStepDelay: CONSTANTS.mockStepDelayMs ?? 1800, - progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140, - progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120 - }; + const { + formState, + dispatchStateChange, + applyBodyDefaults, + applyLockPayload, + clearInstallLock, + clearInstallId, + isInstallLocked, + syncInstallLockFlag, + getInstallLock, + getLockedDatabase + } = State; - const formState = { - appDomain: null, - database: null, - httpPort: null, - httpsPort: null, - emailCertificates: null, - opensslKey: null, - assistantOpenAIKey: null - }; + const { + isValidEmail, + isValidPort, + isValidHostnameInput + } = Validation; + + const { + clearFieldErrors, + setFieldError, + bindErrorClear, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + } = UI; - let activeInstall = null; - let unloadGuard = null; let reviewListener = null; - const dispatchStateChange = (key) => { - if (!key || typeof document === 'undefined') return; - try { - document.dispatchEvent(new CustomEvent('installer:state-change', { - detail: { key, value: formState[key] } - })); - } catch (error) {} - }; - - const clampStep = (step) => { - const numeric = Number(step); - if (Number.isNaN(numeric)) return 1; - return Math.max(1, Math.min(4, numeric)); - }; - - const getInstallLock = () => { - try { - const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - return parsed; - } catch (error) { - return null; - } - }; - - const setInstallLock = (installId, payload) => { - const lock = { - installId, - payload: payload || null, - startedAt: Date.now() - }; - try { - sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); - } catch (error) {} - if (document.body) { - document.body.dataset.installLocked = 'true'; - } - return lock; - }; - - const clearInstallLock = () => { - try { - sessionStorage.removeItem(INSTALL_LOCK_KEY); - } catch (error) {} - if (document.body) { - delete document.body.dataset.installLocked; - } - }; - - const isInstallLocked = () => { - if (isMockProgressMode()) return false; - return Boolean(getInstallLock()); - }; - - const syncInstallLockFlag = () => { - if (!document.body) return; - if (isInstallLocked()) { - document.body.dataset.installLocked = 'true'; - } else { - delete document.body.dataset.installLocked; - } - }; - - const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id); - - const isValidEmail = (email) => { - if (!email) return false; - const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return re.test(email); - }; - - const isValidPort = (value) => { - const numeric = Number(value); - if (!Number.isInteger(numeric)) return false; - return numeric >= 1 && numeric <= 65535; - }; - - const isValidIPv4 = (host) => { - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false; - return host.split('.').every((part) => { - const num = Number(part); - return num >= 0 && num <= 255; - }); - }; - - const isValidIPv6 = (host) => { - try { - const url = new URL(`http://[${host}]`); - return url.hostname.toLowerCase() === host.toLowerCase(); - } catch (error) { - return false; - } - }; - - const isValidHostnameLabel = (label) => { - if (!label || label.length > 63) return false; - if (label.startsWith('-') || label.endsWith('-')) return false; - return /^[a-zA-Z0-9-]+$/.test(label); - }; - - const isValidDomain = (host) => { - if (host.length > 253) return false; - const labels = host.split('.'); - return labels.every((label) => isValidHostnameLabel(label)); - }; - - const isValidHost = (host) => { - if (host === 'localhost') return true; - if (isValidIPv4(host)) return true; - if (isValidIPv6(host)) return true; - return isValidDomain(host); - }; - - const isValidHostnameInput = (value) => { - if (!value) return false; - const trimmed = value.trim(); - if (!trimmed) return false; - - let host = trimmed; - let port = null; - - if (trimmed.startsWith('[')) { - const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); - if (!match) return false; - host = match[1] || ''; - port = match[2] || null; - } else { - const parts = trimmed.split(':'); - if (parts.length > 2) return false; - if (parts.length === 2) { - host = parts[0]; - port = parts[1]; - } - } - - if (port !== null && port !== '' && !isValidPort(port)) { - return false; - } - - return isValidHost(host); - }; - - const clearFieldErrors = (root) => { - if (!root) return; - root.querySelectorAll('.field-error').forEach((node) => node.remove()); - root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => { - node.classList.remove('is-error'); - }); - }; - - const setFieldError = (input, message) => { - if (!input) return; - const group = input.closest('.input-group'); - if (!group) return; - let error = group.querySelector('.field-error'); - let errorText = error?.querySelector('.field-error-text'); - const hasSameMessage = Boolean(errorText && errorText.textContent === message); - const alreadyVisible = Boolean(error && error.classList.contains('is-visible')); - if (!error) { - const template = document.getElementById('field-error-template'); - if (template && template.content) { - const fragment = template.content.cloneNode(true); - error = fragment.querySelector('.field-error'); - group.appendChild(fragment); - } else { - error = document.createElement('div'); - error.className = 'field-error typography-text-xs-400 text-error'; - const text = document.createElement('span'); - text.className = 'field-error-text'; - error.appendChild(text); - group.appendChild(error); - } - errorText = error.querySelector('.field-error-text'); - } - if (errorText) { - errorText.textContent = message; - } - if (!hasSameMessage || !alreadyVisible) { - requestAnimationFrame(() => { - error.classList.add('is-visible'); - }); - } - input.classList.add('is-error'); - const actionWrapper = input.closest('.input-action'); - if (actionWrapper) { - actionWrapper.classList.add('is-error'); - } - }; - - const bindErrorClear = (input) => { - if (!input) return; - const handler = () => { - const group = input.closest('.input-group'); - const error = group?.querySelector('.field-error'); - if (error) { - error.classList.remove('is-visible'); - setTimeout(() => { - error.remove(); - }, TIMINGS.errorClear); - } - input.classList.remove('is-error'); - const actionWrapper = input.closest('.input-action'); - if (actionWrapper) { - actionWrapper.classList.remove('is-error'); - } - }; - input.addEventListener('input', handler); - input.addEventListener('change', handler); - }; - - const setStateIfEmpty = (key, value) => { - if (value === null || value === undefined || value === '') return; - if (formState[key] === null || formState[key] === undefined || formState[key] === '') { - formState[key] = value; - } - }; - - const applyBodyDefaults = () => { - const data = getBodyDataset(); - setStateIfEmpty('appDomain', data.defaultAppDomain); - setStateIfEmpty('httpPort', data.defaultHttpPort); - setStateIfEmpty('httpsPort', data.defaultHttpsPort); - setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); - setStateIfEmpty('opensslKey', data.defaultSecretKey); - setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); - if (data.lockedDatabase) { - formState.database = data.lockedDatabase; - } - if (!isUpgradeMode()) { - setStateIfEmpty('database', data.defaultDatabase); - } - }; - - const toDatabaseLabel = (value) => { - if (!value) return ''; - return value.toLowerCase() === 'mariadb' ? 'MariaDB' : 'MongoDB'; - }; - - const updateDatabaseSelection = (radio, root) => { - if (!radio || !root) return; - const allOptions = root.querySelectorAll('.selector-card'); - allOptions.forEach((option) => option.classList.remove('selected')); - const selectedOption = radio.closest('.selector-card'); - if (selectedOption) { - selectedOption.classList.add('selected'); - } - }; - - const syncResetButton = (input, button) => { - const defaultValue = input.dataset.default ?? ''; - button.disabled = input.value === defaultValue; - }; - - const setupResetButtons = (root) => { - const inputs = root.querySelectorAll('.input-field[data-default]'); - inputs.forEach((input) => { - const button = root.querySelector(`[data-reset-target="${input.id}"]`); - if (!button) return; - - syncResetButton(input, button); - - input.addEventListener('input', () => syncResetButton(input, button)); - button.addEventListener('click', () => { - input.value = input.dataset.default ?? ''; - syncResetButton(input, button); - input.dispatchEvent(new Event('input', { bubbles: true })); - }); - }); - }; - - const toggleAccordion = (button) => { - const content = button.nextElementSibling; - const icon = button.querySelector('.accordion-chevron'); - const isOpen = button.classList.contains('is-open'); - - button.classList.toggle('is-open', !isOpen); - button.setAttribute('aria-expanded', String(!isOpen)); - - if (content) { - if (!isOpen) { - content.classList.add('open'); - content.style.maxHeight = `${content.scrollHeight}px`; - } else { - content.style.maxHeight = '0px'; - content.classList.remove('open'); - } - } - - if (icon) { - icon.setAttribute('data-open', String(!isOpen)); - } - }; - - const setupAccordion = (root) => { - const buttons = root.querySelectorAll('.accordion-toggle'); - buttons.forEach((button) => { - button.addEventListener('click', () => toggleAccordion(button)); - }); - }; - - const setUnloadGuard = (enabled) => { - if (!enabled && unloadGuard) { - window.removeEventListener('beforeunload', unloadGuard); - unloadGuard = null; - return; - } - - if (enabled && !unloadGuard) { - unloadGuard = (event) => { - event.preventDefault(); - event.returnValue = ''; - return ''; - }; - window.addEventListener('beforeunload', unloadGuard); - } - }; - const bindInputToState = (input, key) => { if (!input) return; const update = () => { formState[key] = input.value; - dispatchStateChange(key); + dispatchStateChange?.(key); }; input.addEventListener('input', update); input.addEventListener('change', update); update(); }; - const bindDatabaseSelection = (root) => { - const radios = root.querySelectorAll('input[name="database"]'); - radios.forEach((radio) => { - radio.addEventListener('change', () => { - formState.database = radio.value; - updateDatabaseSelection(radio, root); - }); - }); - }; - const lockDatabaseSelection = (root, lockedDatabase) => { if (lockedDatabase) { const radios = root.querySelectorAll('input[name="database"]'); @@ -435,32 +72,29 @@ } if (isLockedChoice) { radio.checked = true; - updateDatabaseSelection(radio, root); + updateDatabaseSelection?.(radio, root); } }); } }; - const hydrateStep1State = (root) => { - setStateIfEmpty('appDomain', root.querySelector('#hostname')?.value); - setStateIfEmpty('database', root.querySelector('input[name="database"]:checked')?.value); - setStateIfEmpty('httpPort', root.querySelector('#http-port')?.value); - setStateIfEmpty('httpsPort', root.querySelector('#https-port')?.value); - setStateIfEmpty('emailCertificates', root.querySelector('#ssl-email')?.value); - setStateIfEmpty('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); + const bindDatabaseSelection = (root) => { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + radio.addEventListener('change', () => { + formState.database = radio.value; + updateDatabaseSelection?.(radio, root); + }); + }); }; - const applyLockPayload = () => { - const lock = getInstallLock(); - if (!lock || !lock.payload) return; - const payload = lock.payload; - setStateIfEmpty('appDomain', payload.appDomain); - setStateIfEmpty('database', payload.database); - setStateIfEmpty('httpPort', payload.httpPort); - setStateIfEmpty('httpsPort', payload.httpsPort); - setStateIfEmpty('emailCertificates', payload.emailCertificates); - setStateIfEmpty('opensslKey', payload.opensslKey); - setStateIfEmpty('assistantOpenAIKey', payload.assistantOpenAIKey); + const hydrateStep1State = (root) => { + State.setStateIfEmpty?.('appDomain', root.querySelector('#hostname')?.value); + State.setStateIfEmpty?.('database', root.querySelector('input[name="database"]:checked')?.value); + State.setStateIfEmpty?.('httpPort', root.querySelector('#http-port')?.value); + State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value); + State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value); + State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); }; const applyStep1State = (root) => { @@ -485,63 +119,30 @@ const radio = root.querySelector(`input[name="database"][value="${formState.database}"]`); if (radio) { radio.checked = true; - updateDatabaseSelection(radio, root); + updateDatabaseSelection?.(radio, root); } } }; - const openAccordion = (root) => { - const toggle = root.querySelector('.accordion-toggle'); - const content = root.querySelector('.accordion-content'); - if (!toggle || !content) return; - if (!toggle.classList.contains('is-open')) { - toggle.classList.add('is-open'); - toggle.setAttribute('aria-expanded', 'true'); - content.classList.add('open'); - content.style.maxHeight = `${content.scrollHeight}px`; - } - }; - - const disableControls = (root) => { - const inputs = root.querySelectorAll('input, select, textarea'); - inputs.forEach((input) => { - if (input.type === 'radio' || input.type === 'checkbox') { - input.disabled = true; - } else { - input.readOnly = true; - input.setAttribute('aria-disabled', 'true'); - } - }); - - const buttons = root.querySelectorAll('button'); - buttons.forEach((button) => { - if (button.matches('[data-copy-target]')) return; - button.disabled = true; - button.setAttribute('aria-disabled', 'true'); - }); - - root.classList.add('is-locked'); - }; - const initStep1 = (root) => { if (!root) return; - if (isMockMode()) { - clearInstallLock(); - clearInstallId(); + if (isMockMode?.()) { + clearInstallLock?.(); + clearInstallId?.(); } - syncInstallLockFlag(); - applyLockPayload(); - applyBodyDefaults(); + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); hydrateStep1State(root); applyStep1State(root); - if (isInstallLocked()) { - openAccordion(root); - disableControls(root); + if (isInstallLocked?.()) { + openAccordion?.(root); + disableControls?.(root); return; } - const lockedDatabase = getLockedDatabase(); + const lockedDatabase = getLockedDatabase?.() || ''; if (lockedDatabase) { lockDatabaseSelection(root, lockedDatabase); } else { @@ -560,71 +161,22 @@ bindInputToState(sslEmail, 'emailCertificates'); bindInputToState(assistantKey, 'assistantOpenAIKey'); - bindErrorClear(hostname); - bindErrorClear(httpPort); - bindErrorClear(httpsPort); - bindErrorClear(sslEmail); - bindErrorClear(assistantKey); + bindErrorClear?.(hostname); + bindErrorClear?.(httpPort); + bindErrorClear?.(httpsPort); + bindErrorClear?.(sslEmail); + bindErrorClear?.(assistantKey); const checked = root.querySelector('input[name="database"]:checked'); if (checked) { - updateDatabaseSelection(checked, root); + updateDatabaseSelection?.(checked, root); } - setupResetButtons(root); - setupAccordion(root); + setupResetButtons?.(root); + setupAccordion?.(root); Tooltips?.setupTooltipPortals?.(root); }; - const generateSecretKey = () => { - const array = new Uint8Array(32); - window.crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); - }; - - const copyToClipboard = (value, input) => { - if (!value) return; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(value); - return; - } - if (input) { - input.select(); - document.execCommand('copy'); - input.setSelectionRange(0, 0); - return; - } - const textArea = document.createElement('textarea'); - textArea.value = value; - textArea.style.position = 'fixed'; - textArea.style.top = '-9999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - document.execCommand('copy'); - } catch (error) {} finally { - document.body.removeChild(textArea); - } - }; - - const setTooltipText = (wrapper, message) => { - if (!wrapper) return; - const tooltip = wrapper.querySelector('.tooltip'); - if (tooltip && message) { - tooltip.textContent = message; - } - }; - - const resetTooltipText = (wrapper) => { - if (!wrapper) return; - const defaultText = wrapper.dataset.tooltipDefault; - if (!defaultText) return; - setTooltipText(wrapper, defaultText); - }; - - const Tooltips = window.InstallerTooltips || null; - const hydrateStep2State = (root) => { const value = root.querySelector('#secret-key')?.value; if (formState.opensslKey) return; @@ -642,32 +194,32 @@ const initStep2 = (root) => { if (!root) return; - if (isMockMode()) { - clearInstallLock(); - clearInstallId(); + if (isMockMode?.()) { + clearInstallLock?.(); + clearInstallId?.(); } - syncInstallLockFlag(); - applyLockPayload(); - applyBodyDefaults(); + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); hydrateStep2State(root); if (!formState.opensslKey || !formState.opensslKey.trim()) { - formState.opensslKey = generateSecretKey(); - dispatchStateChange('opensslKey'); + formState.opensslKey = generateSecretKey?.(); + dispatchStateChange?.('opensslKey'); } applyStep2State(root); const input = root.querySelector('#secret-key'); if (input) { bindInputToState(input, 'opensslKey'); - bindErrorClear(input); + bindErrorClear?.(input); } const copyButton = root.querySelector('[data-copy-target]'); const tooltipWrapper = copyButton?.closest('.tooltip-wrapper'); if (tooltipWrapper) { - tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText(tooltipWrapper)); - tooltipWrapper.addEventListener('focusin', () => resetTooltipText(tooltipWrapper)); + tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText?.(tooltipWrapper)); + tooltipWrapper.addEventListener('focusin', () => resetTooltipText?.(tooltipWrapper)); } if (copyButton) { @@ -675,18 +227,18 @@ const targetId = copyButton.getAttribute('data-copy-target'); const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; const value = targetInput?.value || ''; - copyToClipboard(value, targetInput); + copyToClipboard?.(value, targetInput); copyButton.blur(); if (tooltipWrapper) { const successText = tooltipWrapper.dataset.tooltipSuccess || 'Copied'; - setTooltipText(tooltipWrapper, successText); + setTooltipText?.(tooltipWrapper, successText); } }); } const regenerateButton = root.querySelector('[data-regenerate-target]'); - if (regenerateButton && !isInstallLocked()) { + if (regenerateButton && !isInstallLocked?.()) { regenerateButton.addEventListener('click', () => { const targetId = regenerateButton.getAttribute('data-regenerate-target'); const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; @@ -698,913 +250,69 @@ regenerateButton.classList.remove('is-rotating'); }; regenerateButton.addEventListener('animationend', handleAnimationEnd, { once: true }); - targetInput.value = generateSecretKey(); + targetInput.value = generateSecretKey?.(); targetInput.dispatchEvent(new Event('input', { bubbles: true })); }); } - if (isInstallLocked()) { - disableControls(root); - } - }; - - const updateReviewSummary = (root) => { - if (!root) return; - const valueNodes = root.querySelectorAll('[data-review-value]'); - valueNodes.forEach((node) => { - const key = node.dataset.reviewValue; - if (!key) return; - let value = formState[key]; - if (key === 'database') { - value = toDatabaseLabel(formState.database); - } - if (value) { - node.textContent = value; - } - }); - - const badge = root.querySelector('[data-review-badge]'); - if (badge) { - const hasKey = Boolean((formState.opensslKey || '').trim()); - badge.textContent = hasKey ? 'Generated' : 'Missing'; - badge.classList.remove('badge-success', 'badge-warning'); - badge.classList.add(hasKey ? 'badge-success' : 'badge-warning'); - } - - const assistantBadge = root.querySelector('[data-review-assistant-badge]'); - if (assistantBadge) { - const hasAssistantKey = Boolean((formState.assistantOpenAIKey || '').trim()); - assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled'; - assistantBadge.classList.remove('badge-success', 'badge-neutral'); - assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral'); + if (isInstallLocked?.()) { + disableControls?.(root); } }; const initStep3 = (root) => { if (!root) return; - if (isMockMode()) { - clearInstallLock(); - clearInstallId(); + if (isMockMode?.()) { + clearInstallLock?.(); + clearInstallId?.(); } - syncInstallLockFlag(); - applyLockPayload(); - applyBodyDefaults(); - updateReviewSummary(root); + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + updateReviewSummary?.(root); if (reviewListener) { document.removeEventListener('installer:state-change', reviewListener); } - reviewListener = () => updateReviewSummary(root); + reviewListener = () => updateReviewSummary?.(root); document.addEventListener('installer:state-change', reviewListener); - if (isInstallLocked()) { - disableControls(root); - } - }; - - const getProgressLabel = (step, status, message) => { - if (!step) return message || ''; - if (status === 'error') { - const normalized = normalizeInstallError(message || ''); - return normalized.summary || 'Installation failed.'; - } - if (status === 'completed') return step.done; - return step.inProgress; - }; - - const updateInstallRow = (row, step, status, message) => { - if (!row || !step) return; - row.dataset.status = status; - row.dataset.step = step.id; - if (status !== 'error') { - row.classList.remove('is-open'); - const toggle = row.querySelector('[data-install-toggle]'); - if (toggle) { - toggle.setAttribute('aria-expanded', 'false'); - } - } - const label = getProgressLabel(step, status, message); - const text = row.querySelector('[data-install-text]'); - if (text) { - text.textContent = label; - } - }; - - const normalizeInstallError = (message) => { - const text = String(message || '').trim(); - if (!text) { - return { summary: '', details: '' }; - } - const colonIndex = text.indexOf(':'); - if (colonIndex > 0 && colonIndex < 80) { - const summary = text.slice(0, colonIndex).trim(); - const details = text.slice(colonIndex + 1).trim(); - return { summary, details }; - } - if (text.length > 180) { - return { summary: text.slice(0, 180).trim() + '…', details: text }; - } - return { summary: text, details: '' }; - }; - - const updateInstallErrorDetails = (row, error) => { - if (!row) return; - const traceNode = row.querySelector('[data-install-trace]'); - const normalized = normalizeInstallError(error?.message || ''); - const output = error?.output || ''; - const trace = error?.trace || ''; - const detailChunks = []; - if (normalized.details) detailChunks.push(normalized.details); - if (output) detailChunks.push(output); - if (trace) detailChunks.push(trace); - const detailText = detailChunks.join('\n\n'); - - if (traceNode) { - traceNode.textContent = detailText; - traceNode.style.display = detailText ? 'block' : 'none'; - } - }; - - const createInstallRow = (template, step) => { - const fragment = template.content.cloneNode(true); - const row = fragment.querySelector('.install-row'); - if (!row) return null; - const toggle = row.querySelector('[data-install-toggle]'); - const setOpenState = (isOpen) => { - row.classList.toggle('is-open', isOpen); - if (toggle) { - toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); - } - }; - const toggleRow = () => { - if (!row.dataset.status || row.dataset.status !== 'error') { - return; - } - setOpenState(!row.classList.contains('is-open')); - }; - row.addEventListener('click', (event) => { - if (event.target.closest('[data-install-retry]')) { - return; - } - if (event.target.closest('[data-install-toggle]')) { - return; - } - if (event.target.closest('.install-row-details')) { - return; - } - toggleRow(); - }); - if (toggle) { - toggle.addEventListener('click', (event) => { - event.stopPropagation(); - toggleRow(); - }); - } - updateInstallRow(row, step, 'in-progress'); - return row; - }; - - const generateInstallId = () => { - if (window.crypto?.randomUUID) { - return window.crypto.randomUUID(); - } - const bytes = new Uint8Array(16); - window.crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); - }; - - const extractHostname = (value) => { - if (!value) return ''; - const trimmed = value.trim(); - if (trimmed.startsWith('[')) { - const end = trimmed.indexOf(']'); - if (end !== -1) { - return trimmed.slice(1, end); - } - return trimmed; - } - const colonCount = (trimmed.match(/:/g) || []).length; - if (colonCount === 1) { - return trimmed.split(':')[0]; - } - return trimmed; - }; - - const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); - - const isLocalHost = (host) => { - if (!host) return false; - const normalized = host.toLowerCase(); - return LOCAL_HOSTS.has(normalized); - }; - - const buildRedirectUrl = () => { - const dataset = getBodyDataset(); - const rawDomain = (formState.appDomain || dataset.defaultAppDomain || '').trim(); - if (!rawDomain) return ''; - const httpPort = (formState.httpPort || dataset.defaultHttpPort || '').trim(); - const httpsPort = (formState.httpsPort || dataset.defaultHttpsPort || '').trim(); - 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(normalizedHost)) { - protocol = 'https'; - port = httpsPort; - } - if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) { - host = `${rawDomain}:${port}`; - } - return `${protocol}://${host}`; - }; - - const redirectToApp = () => { - if (isMockMode()) return; - const url = buildRedirectUrl(); - if (!url) return; - window.location.href = url; - }; - - const notifyInstallComplete = (installId) => { - if (isMockProgressMode()) return Promise.resolve(); - if (!installId) return Promise.resolve(); - return fetch('/install/complete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ installId }) - }).catch(() => {}); - }; - - const getStoredInstallId = () => { - try { - return sessionStorage.getItem(INSTALL_ID_KEY); - } catch (error) { - return null; - } - }; - - const storeInstallId = (installId) => { - try { - sessionStorage.setItem(INSTALL_ID_KEY, installId); - } catch (error) {} - }; - - const clearInstallId = () => { - try { - sessionStorage.removeItem(INSTALL_ID_KEY); - } catch (error) {} - }; - - const buildInstallPayload = (installId) => { - const normalizedSecret = (formState.opensslKey || '').trim(); - if (!normalizedSecret) { - formState.opensslKey = generateSecretKey(); - } - const normalizedDomain = (formState.appDomain || '').trim() || 'localhost'; - const normalizedHttpPort = (formState.httpPort || '').trim() || '80'; - const normalizedHttpsPort = (formState.httpsPort || '').trim() || '443'; - const normalizedEmail = (formState.emailCertificates || '').trim(); - const normalizedAssistantKey = (formState.assistantOpenAIKey || '').trim(); - - return { - installId, - httpPort: normalizedHttpPort, - httpsPort: normalizedHttpsPort, - database: formState.database || 'mongodb', - appDomain: normalizedDomain, - emailCertificates: normalizedEmail, - opensslKey: (formState.opensslKey || '').trim(), - assistantOpenAIKey: normalizedAssistantKey - }; - }; - - const fetchInstallStatus = async (installId) => { - if (isMockProgressMode()) return null; - if (!installId) return null; - const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, { - cache: 'no-store' - }); - if (!response.ok) return null; - const json = await response.json(); - return json.progress || null; - }; - - const readEventStream = async (stream, onEvent) => { - const reader = stream.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let separatorIndex = buffer.indexOf('\n\n'); - - while (separatorIndex !== -1) { - const rawEvent = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - const lines = rawEvent.split('\n'); - let eventName = 'message'; - let data = ''; - - lines.forEach((line) => { - if (line.startsWith('event:')) { - eventName = line.replace('event:', '').trim(); - } else if (line.startsWith('data:')) { - data += line.replace('data:', '').trim(); - } - }); - - if (data) { - try { - onEvent(eventName, JSON.parse(data)); - } catch (error) { - onEvent(eventName, { message: data }); - } - } - - separatorIndex = buffer.indexOf('\n\n'); - } - } - } finally { - try { - reader.releaseLock(); - } catch (error) {} - } - }; - - const initStep4 = (root) => { - if (!root) return; - if (isMockProgressMode()) { - clearInstallLock(); - clearInstallId(); - } - - if (activeInstall?.controller) { - activeInstall.controller.abort(); - } - if (activeInstall?.pollTimer) { - clearInterval(activeInstall.pollTimer); - } - if (activeInstall?.fallbackTimer) { - clearTimeout(activeInstall.fallbackTimer); - } - activeInstall = null; - - const list = root.querySelector('[data-install-list]'); - const template = root.querySelector('#install-row-template'); - if (!list || !template) return; - - list.innerHTML = ''; - const rowsById = new Map(); - const progressState = new Map(); - let pendingProgressTimer = null; - let pendingProgressStep = null; - let pendingCompletionTimer = null; - syncInstallLockFlag(); - applyLockPayload(); - applyBodyDefaults(); - - const ensureRow = (step) => { - if (!step) return null; - if (rowsById.has(step.id)) { - return rowsById.get(step.id); - } - const row = createInstallRow(template, step); - if (!row) return null; - row.classList.add('is-entering'); - list.appendChild(row); - row.getBoundingClientRect(); - requestAnimationFrame(() => { - row.classList.remove('is-entering'); - }); - rowsById.set(step.id, row); - return row; - }; - - const installPanel = root.querySelector('.install-panel'); - let panelHeightCleanup = null; - const animatePanelHeight = (mutate) => { - if (!installPanel) { - mutate(); - return; - } - if (panelHeightCleanup) { - panelHeightCleanup(); - panelHeightCleanup = null; - } - const currentHeight = installPanel.getBoundingClientRect().height; - installPanel.style.height = `${currentHeight}px`; - installPanel.getBoundingClientRect(); - mutate(); - const nextHeight = installPanel.getBoundingClientRect().height; - if (currentHeight === nextHeight) { - installPanel.style.height = ''; - return; - } - installPanel.style.height = `${currentHeight}px`; - installPanel.getBoundingClientRect(); - installPanel.style.height = `${nextHeight}px`; - const cleanup = () => { - installPanel.style.height = ''; - installPanel.removeEventListener('transitionend', onEnd); - }; - const onEnd = (event) => { - if (event.propertyName === 'height') { - cleanup(); - } - }; - panelHeightCleanup = cleanup; - installPanel.addEventListener('transitionend', onEnd); - }; - - const renderProgress = () => { - animatePanelHeight(() => { - const visibleSteps = []; - for (const step of INSTALLATION_STEPS) { - const state = progressState.get(step.id); - if (!state) break; - visibleSteps.push(step); - } - - visibleSteps.forEach((step) => { - const state = progressState.get(step.id); - if (!state) return; - const row = ensureRow(step); - if (row) { - updateInstallRow(row, step, state.status || 'in-progress', state.message); - if (state.status === 'error') { - updateInstallErrorDetails(row, { - message: state.message, - trace: state.details?.trace, - output: state.details?.output - }); - } - } - }); - }); - }; - - const firstStep = INSTALLATION_STEPS[0]; - if (firstStep) { - progressState.set(firstStep.id, { - status: 'in-progress', - message: firstStep.inProgress - }); - } - renderProgress(); - - const applyProgress = (payload) => { - const step = getStepDefinition(payload.step) || { - id: payload.step, - inProgress: payload.message || payload.step, - done: payload.message || payload.step - }; - progressState.set(step.id, { - status: payload.status || 'in-progress', - message: payload.message, - details: payload.details - }); - renderProgress(); - if (activeInstall) { - activeInstall.lastEventAt = Date.now(); - if (payload.status === 'error') { - if (activeInstall.pollTimer) { - clearInterval(activeInstall.pollTimer); - activeInstall.pollTimer = null; - } - if (activeInstall.fallbackTimer) { - clearTimeout(activeInstall.fallbackTimer); - activeInstall.fallbackTimer = null; - } - } - } - scheduleFallback(); - }; - - const handleProgress = (payload) => { - if (!payload || !payload.step) return; - if (pendingProgressTimer && pendingProgressStep === payload.step && payload.status !== 'in-progress') { - clearTimeout(pendingProgressTimer); - pendingProgressTimer = null; - pendingProgressStep = null; - } - if (pendingCompletionTimer) { - clearTimeout(pendingCompletionTimer); - pendingCompletionTimer = null; - } - const step = getStepDefinition(payload.step) || { - id: payload.step, - inProgress: payload.message || payload.step, - done: payload.message || payload.step - }; - if (payload.status === 'in-progress') { - const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id); - 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') { - completionTargets.push({ - step: previousStep, - details: previousState.details - }); - } - } - } - 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; - applyProgress(payload); - }, TIMINGS.progressTransitionDelay); - scheduleFallback(); - return; - } - } - applyProgress(payload); - }; - - const applySnapshot = (snapshot) => { - if (!snapshot || !snapshot.steps) return; - INSTALLATION_STEPS.forEach((step) => { - const detail = snapshot.steps[step.id]; - if (!detail) return; - progressState.set(step.id, { - status: detail.status, - message: detail.message, - details: snapshot.details?.[step.id] - }); - }); - renderProgress(); - }; - - const startPolling = () => { - if (isMockProgressMode()) return; - if (!activeInstall || activeInstall.pollTimer) return; - activeInstall.pollTimer = setInterval(async () => { - if (!activeInstall || activeInstall.completed) return; - const snapshot = await fetchInstallStatus(activeInstall.installId); - if (snapshot) { - applySnapshot(snapshot); - } - }, TIMINGS.installPollInterval); - }; - - const scheduleFallback = () => { - if (isMockProgressMode()) return; - if (!activeInstall) return; - if (activeInstall.fallbackTimer) { - clearTimeout(activeInstall.fallbackTimer); - } - activeInstall.fallbackTimer = setTimeout(() => { - if (!activeInstall) return; - startPolling(); - }, TIMINGS.installFallbackDelay); - }; - - const finalizeInstall = () => { - if (!activeInstall) return; - activeInstall.completed = true; - if (activeInstall.pollTimer) { - clearInterval(activeInstall.pollTimer); - } - if (activeInstall.fallbackTimer) { - clearTimeout(activeInstall.fallbackTimer); - } - setUnloadGuard(false); - }; - - const startInstallStream = async (installId, options = {}) => { - activeInstall = { - installId, - controller: new AbortController(), - lastEventAt: Date.now(), - pollTimer: null, - fallbackTimer: null, - completed: false - }; - - const payload = buildInstallPayload(installId); - if (options.retryStep) { - payload.retryStep = options.retryStep; - } - if (!isMockProgressMode()) { - setInstallLock(installId, payload); - setUnloadGuard(true); - } - - try { - if (options.forceMock || isMockProgressMode()) { - simulateInstallProgress(options); - return; - } - scheduleFallback(); - const response = await fetch('/install', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream' - }, - body: JSON.stringify(payload), - signal: activeInstall.controller.signal - }); - - if (!response.ok || !response.body) { - let errorMessage = null; - try { - const contentType = response.headers.get('Content-Type') || ''; - if (contentType.includes('application/json')) { - const data = await response.json(); - errorMessage = data?.message || null; - } - } catch (error) { - errorMessage = null; - } - if (errorMessage) { - handleProgress({ - step: 'config-files', - status: 'error', - message: errorMessage - }); - finalizeInstall(); - return; - } - startPolling(); - return; - } - - await readEventStream(response.body, (event, data) => { - if (!activeInstall) return; - if (event === 'install-id' && data?.installId) { - activeInstall.installId = data.installId; - storeInstallId(data.installId); - return; - } - if (event === 'progress') { - handleProgress(data); - return; - } - if (event === 'done') { - const lastStep = INSTALLATION_STEPS[INSTALLATION_STEPS.length - 1]; - if (lastStep) { - const lastState = progressState.get(lastStep.id); - if (!lastState || lastState.status !== 'completed') { - progressState.set(lastStep.id, { - status: 'completed', - message: lastStep.done, - details: lastState?.details - }); - renderProgress(); - } - } - finalizeInstall(); - notifyInstallComplete(activeInstall?.installId); - setTimeout(() => redirectToApp(), TIMINGS.redirectDelay); - return; - } - if (event === 'error') { - if (data?.message) { - const existingError = Array.from(progressState.values()).some((state) => state?.status === 'error'); - if (data.step || !existingError) { - let targetStep = data.step; - if (!targetStep) { - for (const candidate of INSTALLATION_STEPS) { - const state = progressState.get(candidate.id); - if (!state || state.status !== 'completed') { - targetStep = candidate.id; - break; - } - } - } - handleProgress({ - step: targetStep || 'config-files', - status: 'error', - message: data.message, - details: data.details - }); - } - } - finalizeInstall(); - } - }); - if (activeInstall && !activeInstall.completed) { - startPolling(); - } - } catch (error) { - if (!activeInstall || activeInstall.controller.signal.aborted) { - return; - } - startPolling(); - } - }; - - const resumeInstall = async (installId) => { - const snapshot = await fetchInstallStatus(installId); - if (!snapshot) return false; - activeInstall = { - installId, - controller: new AbortController(), - lastEventAt: Date.now(), - pollTimer: null, - fallbackTimer: null, - completed: false - }; - applySnapshot(snapshot); - startPolling(); - setUnloadGuard(true); - return true; - }; - - const resetProgressFrom = (stepId) => { - const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId); - if (index === -1) return; - INSTALLATION_STEPS.slice(index).forEach((step) => { - progressState.delete(step.id); - const row = rowsById.get(step.id); - if (row && row.parentNode) { - row.parentNode.removeChild(row); - } - rowsById.delete(step.id); - }); - }; - - const retryInstallStep = (stepId) => { - if (!stepId) return; - if (activeInstall?.controller) { - activeInstall.controller.abort(); - } - if (activeInstall?.pollTimer) { - clearInterval(activeInstall.pollTimer); - } - if (activeInstall?.fallbackTimer) { - clearTimeout(activeInstall.fallbackTimer); - } - resetProgressFrom(stepId); - handleProgress({ - step: stepId, - status: 'in-progress', - message: getStepDefinition(stepId)?.inProgress || 'Retrying...' - }); - const installId = activeInstall?.installId || getInstallLock()?.installId || generateInstallId(); - storeInstallId(installId); - startInstallStream(installId, { retryStep: stepId, forceMock: isMockProgressMode() }); - }; - - list.addEventListener('click', (event) => { - const button = event.target.closest('[data-install-retry]'); - if (!button) return; - const row = button.closest('.install-row'); - const stepId = row?.dataset.step; - retryInstallStep(stepId); - }); - - const simulateInstallProgress = (options = {}) => { - if (activeInstall?.pollTimer) { - clearInterval(activeInstall.pollTimer); - } - const startIndex = options.retryStep - ? Math.max(0, INSTALLATION_STEPS.findIndex((step) => step.id === options.retryStep)) - : 0; - let index = startIndex; - const errorStepId = isMockErrorMode() && !options.retryStep ? 'docker-containers' : null; - const mockErrorDetails = CONSTANTS.mockErrorDetails || { - output: 'Error response from daemon: manifest for appwrite/appwrite:local not found: manifest unknown', - trace: '#0 /usr/src/code/src/Appwrite/Platform/Tasks/Install.php(540): Appwrite\\\\Platform\\\\Tasks\\\\Install->performInstallation(...)\\n#1 {main}' - }; - const advance = () => { - if (index > 0) { - const previous = INSTALLATION_STEPS[index - 1]; - const previousState = progressState.get(previous.id); - if (!previousState || previousState.status !== 'completed') { - handleProgress({ - step: previous.id, - status: 'completed', - message: previous.done - }); - } - } - - if (index >= INSTALLATION_STEPS.length) { - finalizeInstall(); - return; - } - - const step = INSTALLATION_STEPS[index]; - handleProgress({ - step: step.id, - status: 'in-progress', - message: step.inProgress - }); - - if (errorStepId && step.id === errorStepId) { - index += 1; - activeInstall.pollTimer = setTimeout(() => { - handleProgress({ - step: step.id, - status: 'error', - message: 'Failed to start containers', - details: mockErrorDetails - }); - finalizeInstall(); - }, TIMINGS.mockStepDelay); - return; - } - - index += 1; - activeInstall.pollTimer = setTimeout(advance, TIMINGS.mockStepDelay); - }; - advance(); - }; - - if (isMockProgressMode()) { - const newInstallId = generateInstallId(); - storeInstallId(newInstallId); - startInstallStream(newInstallId); - return; - } - - const lock = getInstallLock(); - const existingInstallId = lock?.installId || getStoredInstallId(); - if (existingInstallId) { - resumeInstall(existingInstallId).then((resumed) => { - if (!resumed) { - clearInstallId(); - clearInstallLock(); - const newInstallId = generateInstallId(); - storeInstallId(newInstallId); - startInstallStream(newInstallId); - } - }); - } else { - const newInstallId = generateInstallId(); - storeInstallId(newInstallId); - startInstallStream(newInstallId); + if (isInstallLocked?.()) { + disableControls?.(root); } }; const initStep = (step, container) => { if (!container) return; const root = container.querySelector('.step-layout') || container; - const normalized = clampStep(step); + const normalized = clampStep?.(step) ?? 1; Tooltips?.cleanupTooltipPortals?.(); if (normalized !== 3 && reviewListener) { document.removeEventListener('installer:state-change', reviewListener); reviewListener = null; } - if (normalized !== 4 && activeInstall?.controller) { - activeInstall.controller.abort(); - if (activeInstall.pollTimer) { - clearInterval(activeInstall.pollTimer); - } - if (activeInstall.fallbackTimer) { - clearTimeout(activeInstall.fallbackTimer); - } - activeInstall = null; - setUnloadGuard(false); + if (normalized !== 4) { + Progress.cleanupInstallFlow?.(); } if (normalized === 1) initStep1(root); if (normalized === 2) initStep2(root); if (normalized === 3) initStep3(root); - if (normalized === 4) initStep4(root); + if (normalized === 4) Progress.initStep4?.(root); }; window.InstallerSteps = { initStep1, initStep2, initStep3, - initStep4, - installationSteps: INSTALLATION_STEPS, + initStep4: Progress.initStep4, + installationSteps: INSTALLATION_STEPS || [], isInstallLocked, getInstallLock, clearInstallLock, initStep, validateStep: (step, container) => { const root = container?.querySelector('.step-layout') || container; - const normalized = clampStep(step); + const normalized = clampStep?.(step) ?? 1; if (normalized === 1) { - clearFieldErrors(root); + clearFieldErrors?.(root); let valid = true; const hostname = root?.querySelector('#hostname'); const httpPort = root?.querySelector('#http-port'); @@ -1612,17 +320,17 @@ const sslEmail = root?.querySelector('#ssl-email'); if (!hostname || !hostname.value.trim()) { - setFieldError(hostname, 'Please enter your Appwrite hostname'); + setFieldError?.(hostname, 'Please enter your Appwrite hostname'); valid = false; - } else if (!isValidHostnameInput(hostname.value.trim())) { - setFieldError(hostname, 'Please enter a valid hostname'); + } else if (!isValidHostnameInput?.(hostname.value.trim())) { + setFieldError?.(hostname, 'Please enter a valid hostname'); valid = false; } const parsePort = (input, label) => { const value = input?.value; - if (!value || !isValidPort(value)) { - setFieldError(input, `Please enter a valid ${label} port (1-65535)`); + if (!value || !isValidPort?.(value)) { + setFieldError?.(input, `Please enter a valid ${label} port (1-65535)`); return false; } return true; @@ -1632,30 +340,30 @@ if (!parsePort(httpsPort, 'HTTPS')) valid = false; if (!sslEmail || !sslEmail.value.trim()) { - setFieldError(sslEmail, 'Please enter an email address for SSL certificates'); + setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates'); valid = false; - } else if (!isValidEmail(sslEmail.value.trim())) { - setFieldError(sslEmail, 'Please enter a valid email address'); + } else if (!isValidEmail?.(sslEmail.value.trim())) { + setFieldError?.(sslEmail, 'Please enter a valid email address'); valid = false; } if (!valid) { - openAccordion(root); + openAccordion?.(root); } return valid; } if (normalized === 2) { - clearFieldErrors(root); + clearFieldErrors?.(root); const secretKey = root?.querySelector('#secret-key'); const secretValue = secretKey?.value.trim() || ''; if (!secretKey || !secretValue) { - setFieldError(secretKey, 'Please enter or generate a secret API key'); + setFieldError?.(secretKey, 'Please enter or generate a secret API key'); return false; } if (secretValue.length > 64) { - setFieldError(secretKey, 'Secret API key must be 1-64 characters'); + setFieldError?.(secretKey, 'Secret API key must be 1-64 characters'); return false; } } diff --git a/app/views/install/router.php b/app/views/install/router.php deleted file mode 100644 index 8d48f15c8c..0000000000 --- a/app/views/install/router.php +++ /dev/null @@ -1,260 +0,0 @@ -/dev/null 2>&1'; - \exec($command); -} - -#[NoReturn] -function startInstallerDevServer(string $host, string $port): void -{ - $binary = escapeshellcmd(PHP_BINARY); - $address = escapeshellarg("$host:$port"); - $docroot = escapeshellarg(__DIR__); - $router = escapeshellarg(__FILE__); - $command = "$binary -S $address -t $docroot $router"; - - $descriptorSpec = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $process = proc_open($command, $descriptorSpec, $pipes); - if (!is_resource($process)) { - fwrite(STDERR, "Failed to start PHP dev server." . PHP_EOL); - exit(1); - } - - fclose($pipes[0]); - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - while (true) { - $read = [$pipes[1], $pipes[2]]; - $write = null; - $except = null; - - if (stream_select($read, $write, $except, null) === false) { - break; - } - - foreach ($read as $stream) { - $line = fgets($stream); - if ($line === false) { - continue; - } - - if ($stream === $pipes[2] && preg_match(DEV_SERVER_START_PATTERN, $line)) { - continue; - } - - $target = ($stream === $pipes[2]) ? STDERR : STDOUT; - fwrite($target, $line); - } - - $status = proc_get_status($process); - if (!$status['running']) { - break; - } - } - - foreach ($pipes as $pipe) { - if (is_resource($pipe)) { - fclose($pipe); - } - } - - $exitCode = proc_close($process); - exit($exitCode); -} - -function removeDockerInstallerContainer(string $container): void -{ - $name = escapeshellarg($container); - \exec("docker rm -f {$name} >/dev/null 2>&1"); -} - -function dockerImageExists(string $image): bool -{ - $name = escapeshellarg($image); - $result = 1; - \exec("docker image inspect $name >/dev/null 2>&1", $output, $result); - return $result === 0; -} - -function buildDockerInstallerImage(string $image): void -{ - fwrite(STDOUT, "Building Docker image: {$image}\n"); - $buildCommand = 'docker compose build appwrite'; - passthru($buildCommand, $status); - if ((int)$status !== 0 || !dockerImageExists($image)) { - fwrite(STDERR, "Failed to build Docker image: {$image}\n"); - fwrite(STDERR, "Try: docker compose build appwrite\n"); - exit(1); - } -} - -function ensureLocalInstallerTag(string $source, string $target): void -{ - $sourceArg = escapeshellarg($source); - $targetArg = escapeshellarg($target); - $exists = 1; - \exec("docker image inspect {$targetArg} >/dev/null 2>&1", $output, $exists); - if ($exists === 0) { - return; - } - \exec("docker tag {$sourceArg} {$targetArg}", $tagOutput, $tagStatus); - if ($tagStatus !== 0) { - fwrite(STDERR, "Failed to tag Docker image {$source} as {$target}\n"); - exit(1); - } -} - -#[NoReturn] -function startDockerInstaller(array $opts): void -{ - $container = $opts['container'] ?? INSTALLER_DEFAULT_CONTAINER; - $image = INSTALLER_DEFAULT_IMAGE; - if (!dockerImageExists($image)) { - buildDockerInstallerImage($image); - } - ensureLocalInstallerTag($image, 'appwrite/appwrite:local'); - $port = (string) InstallerServer::INSTALLER_WEB_PORT; - $entrypoint = isset($opts['upgrade']) ? 'upgrade' : 'install'; - - removeDockerInstallerContainer($container); - - $cwd = getcwd() ?: '.'; - $volumePath = $cwd; - $envArgs = []; - $envArgs[] = ['-e', 'APPWRITE_INSTALLER_LOCAL=1']; - if (isset($opts['mock'])) { - $envArgs[] = ['-e', 'APPWRITE_INSTALLER_MOCK=1']; - } - if (!empty($opts['locked-database'])) { - $envArgs[] = ['-e', '_APP_DB_ADAPTER=' . $opts['locked-database']]; - } - - $args = [ - 'docker', - 'run', - '-it', - '--rm', - '--name', $container, - '-p', "$port:" . InstallerServer::INSTALLER_WEB_PORT_INTERNAL, - '--volume', '/var/run/docker.sock:/var/run/docker.sock', - '--volume', "$volumePath:/usr/src/code/appwrite:rw", - ]; - foreach ($envArgs as $envPair) { - $args[] = $envPair[0]; - $args[] = $envPair[1]; - } - $args[] = '--entrypoint=' . $entrypoint; - $args[] = $image; - - $escaped = array_map('escapeshellarg', $args); - $command = implode(' ', $escaped); - passthru($command, $status); - exit($status); -} - -function handleInstallerRequest(): bool -{ - $path = $_SERVER['REQUEST_URI'] ?? ''; - $requestPath = parse_url($path, PHP_URL_PATH) ?? ''; - if ($requestPath === '' || $requestPath === '/') { - $requestPath = '/installer.phtml'; - } - $basePath = realpath(__DIR__); - if ($basePath === false) { - return false; - } - $candidate = $basePath . $requestPath; - $filePath = realpath($candidate); - if ($filePath === false || !str_starts_with($filePath, $basePath . DIRECTORY_SEPARATOR)) { - return false; - } - - $upgradeEnv = getenv('APPWRITE_INSTALLER_UPGRADE'); - if ($upgradeEnv !== false) { - $isUpgrade = $upgradeEnv === '1' || strtolower($upgradeEnv) === 'true'; - } - - $lockedDbEnv = getenv('APPWRITE_INSTALLER_LOCKED_DATABASE'); - if ($lockedDbEnv !== false && $lockedDbEnv !== '') { - $lockedDatabase = $lockedDbEnv; - } - - if (is_file($filePath)) { - $extension = pathinfo($filePath, PATHINFO_EXTENSION); - if (in_array($extension, ['html', 'phtml', 'php'], true)) { - sendInstallerHtmlHeaders(); - include $filePath; - return true; - } - return false; - } - - return false; -} - -if (PHP_SAPI === 'cli') { - $opts = getopt('', ['host::', 'mock', 'upgrade', 'locked-database::', 'docker', 'container::', 'clean']); - $host = $opts['host'] ?? INSTALLER_DEFAULT_HOST; - $port = (string) InstallerServer::INSTALLER_WEB_PORT; - $isMock = isset($opts['mock']); - if (isset($opts['clean'])) { - removeDockerInstallerContainer($opts['container'] ?? INSTALLER_DEFAULT_CONTAINER); - killInstallerDevServers(); - exit(0); - } - if (isset($opts['docker'])) { - printInstallerUrl($isMock, $host, $port); - startDockerInstaller($opts); - } - if ($isMock) { - putenv('APPWRITE_INSTALLER_MOCK=1'); - } - if (isset($opts['upgrade'])) { - putenv('APPWRITE_INSTALLER_UPGRADE=1'); - } - if (!empty($opts['locked-database'])) { - putenv('APPWRITE_INSTALLER_LOCKED_DATABASE=' . $opts['locked-database']); - } - - printInstallerUrl($isMock, $host, $port); - flush(); - startInstallerDevServer($host, $port); -} - -return handleInstallerRequest(); diff --git a/composer.json b/composer.json index 16876ac64a..fb73aba765 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,11 @@ "format": "vendor/bin/pint --config pint.json", "bench": "vendor/bin/phpbench run --report=benchmark", "check": "./vendor/bin/phpstan analyse -c phpstan.neon", - "installer:clean": "php app/views/install/router.php --clean", - "installer:dev": "docker compose build && composer installer:clean && php app/views/install/router.php --docker", - "installer:dev-upgrade": "docker compose build && composer installer:clean && php app/views/install/router.php --docker --upgrade", - "installer:mock": "composer installer:clean && php app/views/install/router.php --mock", - "installer:mock-upgrade": "composer installer:clean && php app/views/install/router.php --mock --upgrade --locked-database=mongodb" + "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean", + "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker", + "installer:dev-upgrade": "docker compose build && php src/Appwrite/Platform/Installer/Server.php --docker --upgrade", + "installer:mock": "composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --mock", + "installer:mock-upgrade": "composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --mock --upgrade --locked-database=mongodb" }, "autoload": { "psr-4": { diff --git a/src/Appwrite/Platform/Installer/HttpHandler.php b/src/Appwrite/Platform/Installer/HttpHandler.php new file mode 100644 index 0000000000..d26ad6628f --- /dev/null +++ b/src/Appwrite/Platform/Installer/HttpHandler.php @@ -0,0 +1,576 @@ +paths = $paths; + $this->state = $state; + $this->config = $state->buildConfig(); + } + + public function handleRequest(): void + { + $uri = urldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); + if ($uri === '') { + $uri = '/'; + } + + if ($this->handleStaticFiles($uri)) { + return; + } + + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $handled = match (true) { + $method === 'GET' && $uri === '/install/status' => $this->handleStatusRequest($uri), + $method === 'POST' && $uri === '/install/validate' => $this->handleValidateRequest($uri), + $method === 'POST' && $uri === '/install/complete' => $this->handleCompleteRequest($uri), + $method === 'POST' && $uri === '/install' => $this->handleInstallRequest($uri), + $uri === '/' => $this->handleInstallerView($uri), + default => false, + }; + + if ($handled) { + return; + } + + http_response_code(404); + echo '404 Not Found'; + } + + private function handleStaticFiles(string $uri): bool + { + if ($uri === '/' || $uri === '') { + return false; + } + + $appViewsPath = realpath($this->paths['views']); + $publicPath = realpath($this->paths['public']); + + if (!$appViewsPath || !$publicPath) { + return false; + } + + $filePath = null; + $extension = strtolower(pathinfo($uri, PATHINFO_EXTENSION)); + if ($extension) { + $candidate = realpath($appViewsPath . $uri); + if ($candidate && str_starts_with($candidate, $appViewsPath . DIRECTORY_SEPARATOR)) { + $filePath = $candidate; + } + if (!$filePath) { + $candidate = realpath($publicPath . $uri); + if ($candidate && str_starts_with($candidate, $publicPath . DIRECTORY_SEPARATOR)) { + $filePath = $candidate; + } + } + } + + if ($filePath && is_file($filePath)) { + $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); + return true; + } + + return false; + } + + private function handleStatusRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'GET' || $uri !== '/install/status') { + return false; + } + + header('Content-Type: application/json'); + + $installId = $this->state->sanitizeInstallId($_GET['installId'] ?? ''); + if ($installId === '') { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Missing installId']); + return true; + } + + $path = $this->state->progressFilePath($installId); + if (!file_exists($path)) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'Install not found']); + return true; + } + + $data = $this->state->readProgressFile($installId); + if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + unset($data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey']); + } + echo json_encode(['success' => true, 'progress' => $data]); + return true; + } + + private function handleValidateRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install/validate') { + return false; + } + + header('Content-Type: application/json'); + $this->validateCsrf(false); + echo json_encode(['success' => true]); + return true; + } + + private function handleCompleteRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install/complete') { + return false; + } + + header('Content-Type: application/json'); + $this->validateCsrf(false); + + $input = json_decode(file_get_contents('php://input'), true); + if (!is_array($input)) { + $input = []; + } + $installId = $this->state->sanitizeInstallId($input['installId'] ?? ''); + + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_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 &'); + } + } + + return true; + } + + private function handleInstallRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install') { + return false; + } + + $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); + + echo "event: ping\n"; + echo "data: {\"time\":" . time() . "}\n\n"; + @ob_flush(); + @flush(); + } else { + header('Content-Type: application/json'); + } + + $this->validateCsrf($wantsStream); + + $rawBody = file_get_contents('php://input'); + $input = json_decode($rawBody, true); + + if (!$input) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid request']); + return true; + } + + $appDomain = trim((string) ($input['appDomain'] ?? '')); + if ($appDomain === '' || !$this->state->isValidAppDomainInput($appDomain)) { + $this->respondBadRequest('Please enter a valid hostname', $wantsStream); + } + $input['appDomain'] = $appDomain; + + $httpPort = $input['httpPort'] ?? ''; + if (!$this->state->isValidPort($httpPort)) { + $this->respondBadRequest('Please enter a valid HTTP port (1-65535)', $wantsStream); + } + + $httpsPort = $input['httpsPort'] ?? ''; + if (!$this->state->isValidPort($httpsPort)) { + $this->respondBadRequest('Please enter a valid HTTPS port (1-65535)', $wantsStream); + } + + $emailCertificates = trim((string) ($input['emailCertificates'] ?? '')); + if ($emailCertificates === '' || !$this->state->isValidEmailAddress($emailCertificates)) { + $this->respondBadRequest('Please enter a valid email address', $wantsStream); + } + $input['emailCertificates'] = $emailCertificates; + + $opensslKey = trim((string) ($input['opensslKey'] ?? '')); + if ($opensslKey === '' || strlen($opensslKey) > 64) { + $this->respondBadRequest('Secret API key must be 1-64 characters', $wantsStream); + } + $input['opensslKey'] = $opensslKey; + + $assistantOpenAIKey = trim((string) ($input['assistantOpenAIKey'] ?? '')); + $input['assistantOpenAIKey'] = $assistantOpenAIKey; + + $lockedDatabase = $this->config->getLockedDatabase(); + if (!$lockedDatabase) { + $database = strtolower(trim((string) ($input['database'] ?? ''))); + if (!$this->state->isValidDatabaseAdapter($database)) { + $this->respondBadRequest('Please select a supported database', $wantsStream); + } + $input['database'] = $database; + } + + $installId = $this->state->sanitizeInstallId($input['installId'] ?? ''); + if ($installId === '') { + $installId = bin2hex(random_bytes(8)); + } + + try { + $lockResult = $this->state->reserveGlobalLock($installId); + } catch (\Throwable $e) { + if ($wantsStream) { + $this->sendSseEvent(Server::STATUS_ERROR, ['message' => 'Lock failed: ' . $e->getMessage()]); + } else { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Lock failed: ' . $e->getMessage()]); + } + return true; + } + + 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']); + } + return true; + } + + $retryStep = $input['retryStep'] ?? null; + $allowedRetrySteps = [Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS]; + if (!is_string($retryStep) || !in_array($retryStep, $allowedRetrySteps, true)) { + $retryStep = null; + } + + $existingPath = $this->state->progressFilePath($installId); + $existing = null; + if (file_exists($existingPath)) { + $existing = $this->state->readProgressFile($installId); + if (!empty($existing['steps']) && $retryStep === null) { + http_response_code(409); + echo json_encode(['success' => false, 'message' => 'Installation already started']); + return true; + } + } + + try { + ignore_user_abort(true); + $this->state->ensureBootstrapped(); + require_once $this->paths['installPhp']; + $installer = new \Appwrite\Platform\Tasks\Install(); + + if ($wantsStream) { + $this->sendSseEvent('install-id', ['installId' => $installId]); + } + + $this->state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + + $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' => $lockedDatabase ?? ($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])) { + $storedValue = (string) $stored[$field]; + $inputValue = (string) $input[$field]; + if ($field === 'httpPort' || $field === 'httpsPort') { + $storedValue = trim($storedValue); + $inputValue = trim($inputValue); + } + if ($storedValue !== $inputValue) { + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->respondBadRequest('Installation payload mismatch', $wantsStream); + } + } + } + + $sensitiveFields = [ + 'opensslKey' => 'opensslKeyHash', + 'assistantOpenAIKey' => 'assistantOpenAIKeyHash', + ]; + foreach ($sensitiveFields as $field => $hashField) { + if (!isset($stored[$hashField]) && !isset($stored[$field])) { + continue; + } + $incomingHash = $this->state->hashSensitiveValue((string) ($input[$field] ?? '')); + if (isset($stored[$hashField])) { + if (!hash_equals((string) $stored[$hashField], $incomingHash)) { + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->respondBadRequest('Installation payload mismatch', $wantsStream); + } + } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->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'] = $lockedDatabase ?? ($stored['database'] ?? $payloadInput['_APP_DB_ADAPTER']); + $input['httpPort'] = $stored['httpPort'] ?? $input['httpPort'] ?? $this->config->getDefaultHttpPort(); + $input['httpsPort'] = $stored['httpsPort'] ?? $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(); + } + + $vars = $this->config->getVars(); + $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars); + + $this->state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => $input['httpPort'] ?? $this->config->getDefaultHttpPort(), + 'httpsPort' => $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(), + 'database' => $input['database'] ?? 'mongodb', + 'appDomain' => $input['appDomain'] ?? 'localhost', + 'emailCertificates' => $input['emailCertificates'] ?? '', + 'opensslKeyHash' => $this->state->hashSensitiveValue((string) ($input['opensslKey'] ?? '')), + 'assistantOpenAIKeyHash' => $this->state->hashSensitiveValue((string) ($input['assistantOpenAIKey'] ?? '')), + ], + 'step' => 'start', + 'status' => Server::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; + } + $this->state->writeProgressFile($installId, $payload); + $this->state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + if ($wantsStream) { + $this->sendSseEvent('progress', $payload); + } + }; + + $installer->performInstallation( + $input['httpPort'] ?? $this->config->getDefaultHttpPort(), + $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(), + $this->config->getOrganization(), + $this->config->getImage(), + $envVars, + $this->config->getNoStart(), + $progress, + $retryStep, + $this->config->isUpgrade() + ); + + if ($wantsStream) { + $this->sendSseEvent('done', ['installId' => $installId, 'success' => true]); + usleep(500000); + echo ": keepalive\n\n"; + @ob_flush(); + @flush(); + usleep(500000); + } else { + echo json_encode([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully', + ]); + } + $this->state->updateGlobalLock($installId, Server::STATUS_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(); + } + $this->state->writeProgressFile($installId, [ + 'step' => Server::STATUS_ERROR, + 'status' => Server::STATUS_ERROR, + 'message' => $e->getMessage(), + 'details' => $details, + 'updatedAt' => time(), + ]); + } + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + if ($wantsStream) { + $details = ['trace' => $e->getTraceAsString()]; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + $this->sendSseEvent(Server::STATUS_ERROR, ['message' => $e->getMessage(), 'details' => $details]); + } else { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + } + + return true; + } + + private function handleInstallerView(string $uri): bool + { + if ($uri !== '/' && $uri !== '') { + return false; + } + + $csrfToken = $this->makeCsrf(); + $this->sendInstallerHtmlHeaders(); + + $vars = $this->config->getVars(); + $defaultHttpPort = $this->config->getDefaultHttpPort(); + $defaultHttpsPort = $this->config->getDefaultHttpsPort(); + $isUpgrade = $this->config->isUpgrade(); + $lockedDatabase = $this->config->getLockedDatabase(); + $isMock = $this->config->isMock(); + $isLocalInstall = $this->config->isLocal(); + + include $this->paths['views'] . '/installer.phtml'; + return true; + } + + private function sendInstallerHtmlHeaders(): void + { + header('Content-Type: text/html; charset=utf-8'); + header('Content-Security-Policy: ' . implode('; ', self::INSTALLER_CSP)); + } + + private function makeCsrf(): string + { + $existing = $_COOKIE[self::CSRF_COOKIE] ?? ''; + if (is_string($existing) && $existing !== '') { + return $existing; + } + + $token = bin2hex(random_bytes(16)); + setcookie(self::CSRF_COOKIE, $token, [ + 'path' => '/', + 'samesite' => 'Strict', + 'httponly' => true, + ]); + return $token; + } + + private function validateCsrf(bool $wantsStream): void + { + $cookie = $_COOKIE[self::CSRF_COOKIE] ?? ''; + $header = $_SERVER['HTTP_X_APPWRITE_INSTALLER_CSRF'] ?? ''; + + if (!is_string($cookie) || !is_string($header) || $cookie === '' || $header === '' || !hash_equals($cookie, $header)) { + $this->respondBadRequest('Invalid CSRF token', $wantsStream); + } + } + + private function sendSseEvent(string $event, array $payload): void + { + echo "event: $event\n"; + echo 'data: ' . json_encode($payload) . "\n\n"; + @ob_flush(); + @flush(); + } + + private function respondBadRequest(string $message, bool $wantsStream): void + { + if ($wantsStream) { + $this->sendSseEvent(Server::STATUS_ERROR, ['message' => $message, 'step' => Server::STEP_CONFIG_FILES]); + } else { + http_response_code(400); + echo json_encode(['success' => false, 'message' => $message]); + } + exit; + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php new file mode 100644 index 0000000000..4161a6e444 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -0,0 +1,217 @@ +containsKnownKeys($values)) { + $this->setVars($values); + return; + } + $this->apply($values); + } + + public function apply(array $values): void + { + if (array_key_exists('defaultHttpPort', $values) && $values['defaultHttpPort'] !== null && $values['defaultHttpPort'] !== '') { + $this->setDefaultHttpPort((string) $values['defaultHttpPort']); + } + if (array_key_exists('defaultHttpsPort', $values) && $values['defaultHttpsPort'] !== null && $values['defaultHttpsPort'] !== '') { + $this->setDefaultHttpsPort((string) $values['defaultHttpsPort']); + } + if (array_key_exists('organization', $values) && $values['organization'] !== null && $values['organization'] !== '') { + $this->setOrganization((string) $values['organization']); + } + if (array_key_exists('image', $values) && $values['image'] !== null && $values['image'] !== '') { + $this->setImage((string) $values['image']); + } + if (array_key_exists('noStart', $values) && $values['noStart'] !== null) { + $this->setNoStart((bool) $values['noStart']); + } + if (array_key_exists('isUpgrade', $values) && $values['isUpgrade'] !== null) { + $this->setIsUpgrade((bool) $values['isUpgrade']); + } + if (array_key_exists('isLocal', $values) && $values['isLocal'] !== null) { + $this->setIsLocal((bool) $values['isLocal']); + } + if (array_key_exists('isMock', $values) && $values['isMock'] !== null) { + $this->setIsMock((bool) $values['isMock']); + } + if (array_key_exists('hostPath', $values)) { + $hostPath = $values['hostPath']; + $this->setHostPath($hostPath !== null && $hostPath !== '' ? (string) $hostPath : null); + } + if (array_key_exists('lockedDatabase', $values) && $values['lockedDatabase'] !== null && $values['lockedDatabase'] !== '') { + $locked = $values['lockedDatabase']; + $this->setLockedDatabase($locked !== null && $locked !== '' ? (string) $locked : null); + } + if (array_key_exists('vars', $values) && is_array($values['vars'])) { + $this->setVars($values['vars']); + } + } + + private function containsKnownKeys(array $values): bool + { + foreach (self::KNOWN_KEYS as $key) { + if (array_key_exists($key, $values)) { + return true; + } + } + return false; + } + + public function toArray(): array + { + return [ + 'defaultHttpPort' => $this->defaultHttpPort, + 'defaultHttpsPort' => $this->defaultHttpsPort, + 'organization' => $this->organization, + 'image' => $this->image, + 'noStart' => $this->noStart, + 'vars' => $this->vars, + 'isUpgrade' => $this->isUpgrade, + 'isLocal' => $this->isLocal, + 'isMock' => $this->isMock, + 'hostPath' => $this->hostPath, + 'lockedDatabase' => $this->lockedDatabase, + ]; + } + + public function getDefaultHttpPort(): string + { + return $this->defaultHttpPort; + } + + public function setDefaultHttpPort(string $value): void + { + $this->defaultHttpPort = $value; + } + + public function getDefaultHttpsPort(): string + { + return $this->defaultHttpsPort; + } + + public function setDefaultHttpsPort(string $value): void + { + $this->defaultHttpsPort = $value; + } + + public function getOrganization(): string + { + return $this->organization; + } + + public function setOrganization(string $value): void + { + $this->organization = $value; + } + + public function getImage(): string + { + return $this->image; + } + + public function setImage(string $value): void + { + $this->image = $value; + } + + public function getNoStart(): bool + { + return $this->noStart; + } + + public function setNoStart(bool $value): void + { + $this->noStart = $value; + } + + public function getVars(): array + { + return $this->vars; + } + + public function setVars(array $vars): void + { + $this->vars = $vars; + } + + public function isUpgrade(): bool + { + return $this->isUpgrade; + } + + public function setIsUpgrade(bool $value): void + { + $this->isUpgrade = $value; + } + + public function isLocal(): bool + { + return $this->isLocal; + } + + public function setIsLocal(bool $value): void + { + $this->isLocal = $value; + } + + public function isMock(): bool + { + return $this->isMock; + } + + public function setIsMock(bool $value): void + { + $this->isMock = $value; + } + + public function getHostPath(): ?string + { + return $this->hostPath; + } + + public function setHostPath(?string $value): void + { + $this->hostPath = $value; + } + + public function getLockedDatabase(): ?string + { + return $this->lockedDatabase; + } + + public function setLockedDatabase(?string $value): void + { + $this->lockedDatabase = $value; + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php new file mode 100644 index 0000000000..102c695c2f --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -0,0 +1,394 @@ +paths = $paths; + } + + public function buildConfig(array $overrides = [], bool $useEnv = true): Config + { + $cfg = new Config(); + $configJson = null; + $decodedOk = false; + if ($useEnv) { + $configJson = getenv('APPWRITE_INSTALLER_CONFIG'); + if ($configJson !== false && $configJson !== '') { + $decoded = json_decode($configJson, true); + if (is_array($decoded)) { + $cfg->apply($decoded); + $decodedOk = true; + } + } + } + if ($useEnv && (!$decodedOk)) { + $fileConfig = $this->readConfigFile(); + if (is_array($fileConfig)) { + $cfg->apply($fileConfig); + } + } + + if ($cfg->isLocal() && empty($cfg->getVars())) { + $envPath = dirname($this->paths['init'], 2) . '/.env'; + if (file_exists($envPath)) { + $envContent = file_get_contents($envPath); + if ($envContent !== false) { + $vars = $this->parseEnvFile($envContent); + if (!empty($vars)) { + $cfg->setVars($vars); + } + } + } + } + + $cfg->apply($overrides); + + return $cfg; + } + + public function applyEnvConfig(Config|array $cfg): void + { + $values = $cfg instanceof Config ? $cfg->toArray() : $cfg; + $json = json_encode($values, JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + return; + } + putenv('APPWRITE_INSTALLER_CONFIG=' . $json); + $this->writeConfigFile($json); + } + + private function readConfigFile(): ?array + { + $path = Server::INSTALLER_CONFIG_FILE; + if (!file_exists($path)) { + return null; + } + $contents = file_get_contents($path); + if ($contents === false || $contents === '') { + return null; + } + $decoded = json_decode($contents, true); + return is_array($decoded) ? $decoded : null; + } + + private function writeConfigFile(string $json): void + { + $path = Server::INSTALLER_CONFIG_FILE; + if (@file_put_contents($path, $json) === false) { + return; + } + @chmod($path, 0600); + } + + + public function ensureBootstrapped(): void + { + if ($this->bootstrapped) { + return; + } + + require_once $this->paths['vendor']; + require_once $this->paths['init']; + $this->bootstrapped = true; + } + + public function sanitizeInstallId($value): string + { + if (!is_string($value)) { + return ''; + } + + $clean = preg_replace('/[^a-zA-Z0-9_-]/', '', $value); + if (!is_string($clean)) { + return ''; + } + + return substr($clean, 0, 64); + } + + public function hashSensitiveValue(string $value): string + { + $trimmed = trim($value); + if ($trimmed === '') { + return ''; + } + return hash('sha256', $trimmed); + } + + public function isValidPort($value): bool + { + $string = (string) $value; + if ($string === '' || !preg_match('/^\d+$/', $string)) { + return false; + } + $port = (int) $string; + return $port >= 1 && $port <= 65535; + } + + public function isValidEmailAddress(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; + } + + public 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 !== '' && !$this->isValidPort($port)) { + return false; + } + + return $this->isValidAppDomain($host); + } + + public function isValidDatabaseAdapter(string $value): bool + { + return in_array($value, ['mongodb', 'mariadb'], true); + } + + public function progressFilePath(string $installId): string + { + return sys_get_temp_dir() . '/appwrite-install-' . $installId . '.json'; + } + + public function reserveGlobalLock(string $installId): string + { + return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) { + if (!$handle) { + return 'unavailable'; + } + if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + return 'locked'; + } + $payload = [ + 'installId' => $installId, + 'status' => Server::STATUS_IN_PROGRESS, + 'updatedAt' => time(), + ]; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($payload)); + return 'ok'; + }); + } + + public function updateGlobalLock(string $installId, string $status): void + { + $this->withGlobalLock(function ($handle, $lock) use ($installId, $status) { + if (!$handle) { + return; + } + if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + return; + } + $payload = [ + 'installId' => $installId, + 'status' => $status, + 'updatedAt' => time(), + ]; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($payload)); + }); + } + + public function readProgressFile(string $installId): array + { + $path = $this->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; + } + + public function writeProgressFile(string $installId, array $payload): void + { + $data = $this->readProgressFile($installId); + if (!isset($data['steps']) || !is_array($data['steps'])) { + $data['steps'] = []; + } + + if (!empty($payload['step'])) { + $data['steps'][$payload['step']] = [ + 'status' => $payload['status'] ?? Server::STATUS_IN_PROGRESS, + 'message' => $payload['message'] ?? '', + 'updatedAt' => $payload['updatedAt'] ?? time(), + ]; + } + + if (!empty($payload['status']) && $payload['status'] === Server::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($this->progressFilePath($installId), json_encode($data)); + } + + private function parseEnvFile(string $contents): array + { + $vars = []; + foreach (preg_split('/\r\n|\n|\r/', $contents) ?: [] as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + $pos = strpos($line, '='); + if ($pos === false) { + continue; + } + $key = trim(substr($line, 0, $pos)); + $value = trim(substr($line, $pos + 1)); + if ($key === '') { + continue; + } + $value = $this->stripEnvQuotes($value); + + $vars[] = [ + 'name' => $key, + 'default' => $value, + ]; + } + + return $vars; + } + + private function stripEnvQuotes(string $value): string + { + if ($value === '') { + return $value; + } + $first = $value[0]; + $last = substr($value, -1); + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + $value = substr($value, 1, -1); + } + return $value; + } + + private function globalLockPath(): string + { + return sys_get_temp_dir() . '/appwrite-install-lock.json'; + } + + private function isGlobalLockActive(?array $lock): bool + { + if (!$lock || !isset($lock['updatedAt'])) { + return false; + } + + if (isset($lock['status']) && in_array($lock['status'], [Server::STATUS_COMPLETED, Server::STATUS_ERROR], true)) { + return false; + } + + if (time() - (int) $lock['updatedAt'] > 3600) { + return false; + } + + return true; + } + + private function withGlobalLock(callable $callback) + { + $path = $this->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; + } + } + + try { + $result = $callback($handle, $lock); + } finally { + fflush($handle); + flock($handle, LOCK_UN); + fclose($handle); + } + + return $result; + } + + private 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; + } +} diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 6b23bfb823..03b4b07d56 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -2,766 +2,324 @@ namespace Appwrite\Platform\Installer; +require_once __DIR__ . '/HttpHandler.php'; +require_once __DIR__ . '/Runtime/State.php'; +require_once __DIR__ . '/Runtime/Config.php'; + +use Appwrite\Platform\Installer\Runtime\State; + class Server { public const int INSTALLER_WEB_PORT = 20080; - public const int INSTALLER_WEB_PORT_INTERNAL = 20081; + public const int INSTALLER_WEB_PORT_INTERNAL = 20080; + public const string INSTALLER_CONFIG_FILE = '/tmp/appwrite-installer-config.json'; - public static function writeRouterScript( - string $path, - string $installPhpPath, - string $appViewsPath, - string $publicPath, - string $vendorPath, - string $appwritePath, - string $defaultHTTPPort, - string $defaultHTTPSPort, - string $organization, - string $image, - bool $noStart, - array $vars, - bool $isUpgrade = false, - ?string $lockedDatabase = null - ): void { - $script = self::renderRouterScript( - $installPhpPath, - $appViewsPath, - $publicPath, - $vendorPath, - $appwritePath, - $defaultHTTPPort, - $defaultHTTPSPort, - $organization, - $image, - $noStart, - $vars, - $isUpgrade, - $lockedDatabase - ); + public const string STEP_ENV_VARS = 'env-vars'; + public const string STEP_CONFIG_FILES = 'config-files'; + public const string STEP_DOCKER_COMPOSE = 'docker-compose'; + public const string STEP_DOCKER_CONTAINERS = 'docker-containers'; - if (file_put_contents($path, $script) === false) { - throw new \RuntimeException('Failed to write installer router script.'); - } - } + public const string STATUS_IN_PROGRESS = 'in-progress'; + public const string STATUS_COMPLETED = 'completed'; + public const string STATUS_ERROR = 'error'; - private static function renderRouterScript( - string $installPhpPath, - string $appViewsPath, - string $publicPath, - string $vendorPath, - string $appwritePath, - string $defaultHTTPPort, - string $defaultHTTPSPort, - string $organization, - string $image, - bool $noStart, - array $vars, - bool $isUpgrade, - ?string $lockedDatabase - ): string { - $script = <<<'PHP' -initPaths(); -function sanitizeInstallId($value): string -{ - if (!is_string($value)) { - return ''; - } + $this->state = new State($this->paths); - $clean = preg_replace('/[^a-zA-Z0-9_-]/', '', $value); - if (!is_string($clean)) { - return ''; - } - - return substr($clean, 0, 64); -} - -function progressFilePath(string $installId): string -{ - return sys_get_temp_dir() . '/appwrite-install-' . $installId . '.json'; -} - -function globalLockPath(): string -{ - return sys_get_temp_dir() . '/appwrite-install-lock.json'; -} - -function isGlobalLockActive(?array $lock): bool -{ - if (!$lock || !isset($lock['updatedAt'])) { - return false; - } - - if (isset($lock['status']) && in_array($lock['status'], ['completed', 'error'], true)) { - return false; - } - - if (time() - (int) $lock['updatedAt'] > 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) { + if (PHP_SAPI === 'cli') { + $this->runCli(); return; } - if (isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + + $handler = new HttpHandler($this->paths, $this->state); + + $handler->handleRequest(); + } + + private function initPaths(): void + { + if (!empty($this->paths)) { 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' => [], + $root = dirname(__DIR__, 4); + $this->paths = [ + 'public' => $root . '/public', + 'init' => $root . '/app/init.php', + 'views' => $root . '/app/views/install', + 'vendor' => $root . '/vendor/autoload.php', + 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', ]; } - $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'); + private function runCli(): void + { + $opts = getopt('', ['mock', 'upgrade', 'locked-database::', 'docker', 'clean']); + $cfg = $this->state->buildConfig([], true); + $isDocker = isset($opts['docker']); + if ($isDocker) { + $cfg->setIsLocal(true); + if ($cfg->getHostPath() === null) { + $cwd = getcwd(); + if ($cwd !== false) { + $cfg->setHostPath($cwd); } - respondBadRequest('Installation payload mismatch', $wantsStream); } } + if (isset($opts['mock'])) { + $cfg->setIsMock(true); + } + if (isset($opts['upgrade'])) { + $cfg->setIsUpgrade(true); + } + if (!empty($opts['locked-database'])) { + $cfg->setLockedDatabase($opts['locked-database']); + } + $this->state->applyEnvConfig($cfg); - $sensitiveFields = [ - 'opensslKey' => 'opensslKeyHash', - 'assistantOpenAIKey' => 'assistantOpenAIKeyHash', + $isLocalInstall = $cfg->isLocal(); + $host = '0.0.0.0'; + $port = $isLocalInstall ? (string)self::INSTALLER_WEB_PORT_INTERNAL : (string)self::INSTALLER_WEB_PORT; + $isMock = $cfg->isMock(); + + if (isset($opts['clean'])) { + $this->removeDockerInstallerContainer(self::DEFAULT_CONTAINER); + $this->killInstallerDevServers(); + $this->cleanupWebInstallerFiles(); + exit(0); + } + + if (isset($opts['docker'])) { + $this->printInstallerUrl($isMock, $host, $port); + $this->startDockerInstaller($opts); + } + + $this->printInstallerUrl($isMock, $host, $port); + $this->startInstallerDevServer($host, $port); + } + + private function printInstallerUrl(bool $isMock, string $host, string $port): void + { + $displayHost = $host === '0.0.0.0' ? 'localhost' : $host; + $url = "http://$displayHost:$port"; + $message = $isMock ? "Mock mode enabled: $url" : "Open $url"; + fwrite(STDOUT, $message . PHP_EOL); + } + + private function killInstallerDevServers(): void + { + if (PHP_OS_FAMILY === 'Windows') { + return; + } + + $pattern = 'php -S .*app/views/install'; + $command = 'pkill -f ' . escapeshellarg($pattern) . ' >/dev/null 2>&1'; + exec($command); + } + + private function startInstallerDevServer(string $host, string $port): void + { + $binary = escapeshellcmd(PHP_BINARY); + $address = escapeshellarg("$host:$port"); + $docroot = escapeshellarg($this->paths['views']); + $router = escapeshellarg(__FILE__); + $command = "$binary -S $address -t $docroot $router"; + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], ]; - foreach ($sensitiveFields as $field => $hashField) { - if (!isset($stored[$hashField]) && !isset($stored[$field])) { - continue; + + $process = proc_open($command, $descriptorSpec, $pipes); + if (!is_resource($process)) { + fwrite(STDERR, "Failed to start PHP dev server." . PHP_EOL); + exit(1); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + while (true) { + $read = [$pipes[1], $pipes[2]]; + $write = null; + $except = null; + + if (stream_select($read, $write, $except, null) === false) { + break; } - $incomingHash = hashSensitiveValue((string) ($input[$field] ?? '')); - if (isset($stored[$hashField])) { - if ((string) $stored[$hashField] !== $incomingHash) { - if ($installId !== '') { - updateGlobalLock($installId, 'error'); - } - respondBadRequest('Installation payload mismatch', $wantsStream); + + foreach ($read as $stream) { + $line = fgets($stream); + if ($line === false) { + continue; } - } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { - if ($installId !== '') { - updateGlobalLock($installId, 'error'); + + if ($stream === $pipes[2] && preg_match(self::DEV_SERVER_START_PATTERN, $line)) { + continue; } - respondBadRequest('Installation payload mismatch', $wantsStream); + if (preg_match('/]\s+\S+:\d+\s+(Accepted|Closing)/', $line)) { + continue; + } + + $target = ($stream === $pipes[2]) ? STDERR : STDOUT; + fwrite($target, $line); + } + + $status = proc_get_status($process); + if (!$status['running']) { + break; } } - $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; + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } } - // Use the prepareEnvironmentVariables method to merge with defaults - $vars = json_decode(VARS_JSON, true); - $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars); + $exitCode = proc_close($process); + exit($exitCode); + } - 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(), - ]); + private function removeDockerInstallerContainer(string $container): void + { + $name = escapeshellarg($container); + exec("docker rm -f $name >/dev/null 2>&1"); + } - $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' - ]); + private function cleanupWebInstallerFiles(): void + { + $cwd = getcwd(); + if ($cwd === false) { + return; } - 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(); + $filesToRemove = [ + $cwd . '/.env.web-installer', + $cwd . '/docker-compose.web-installer.yml', + ]; + + foreach ($filesToRemove as $file) { + if (file_exists($file)) { + @unlink($file); } - 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()]); + + $tempDir = sys_get_temp_dir(); + @unlink($tempDir . '/appwrite-install-lock.json'); + foreach (glob($tempDir . '/appwrite-install-*.json') ?: [] as $file) { + @unlink($file); } } - exit; -} -// Serve installer UI -if ($uri === '/' || $uri === '') { - sendInstallerHtmlHeaders(); + private function dockerImageExists(string $image): bool + { + $name = escapeshellarg($image); + $result = 1; + exec("docker image inspect $name >/dev/null 2>&1", $output, $result); + return $result === 0; + } - $vars = json_decode(VARS_JSON, true); - $defaultHttpPort = DEFAULT_HTTP_PORT; - $defaultHttpsPort = DEFAULT_HTTPS_PORT; - $isUpgrade = IS_UPGRADE; - $lockedDatabase = LOCKED_DATABASE; + private function buildDockerInstallerImage(string $image): void + { + fwrite(STDOUT, "Building Docker image: {$image}\n"); + $buildCommand = 'docker compose build appwrite'; + passthru($buildCommand, $status); + if ($status !== 0 || !$this->dockerImageExists($image)) { + fwrite(STDERR, "Failed to build Docker image: $image\n"); + fwrite(STDERR, "Try: docker compose build appwrite\n"); + exit(1); + } + } - include APP_VIEWS_PATH . '/' . INSTALLER_VIEW; - exit; -} + private function ensureLocalInstallerTag(string $source, string $target): void + { + $sourceArg = escapeshellarg($source); + $targetArg = escapeshellarg($target); + exec("docker tag {$sourceArg} {$targetArg}", $tagOutput, $tagStatus); + if ($tagStatus !== 0) { + fwrite(STDERR, "Failed to tag Docker image {$source} as {$target}\n"); + exit(1); + } + } -// 404 -http_response_code(404); -echo '404 Not Found'; -PHP; + private function startDockerInstaller(array $opts): void + { + $image = self::DEFAULT_IMAGE; + $container = self::DEFAULT_CONTAINER; + if (!$this->dockerImageExists($image)) { + $this->buildDockerInstallerImage($image); + } + $this->ensureLocalInstallerTag($image, 'appwrite/appwrite:local'); + $port = (string)self::INSTALLER_WEB_PORT; + $entrypoint = isset($opts['upgrade']) ? 'upgrade' : 'install'; - $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) - ); + $this->removeDockerInstallerContainer($container); + + $root = realpath(dirname(__DIR__, 4)); + $volumePath = $root !== false ? $root : (getcwd() ?: '.'); + $dockerConfig = $this->state->buildConfig([], false); + $dockerConfig->setIsLocal(true); + $dockerConfig->setHostPath($volumePath); + if (isset($opts['mock'])) { + $dockerConfig->setIsMock(true); + } + if (isset($opts['upgrade'])) { + $dockerConfig->setIsUpgrade(true); + } + if (!empty($opts['locked-database'])) { + $dockerConfig->setLockedDatabase($opts['locked-database']); + } + $configJson = json_encode($dockerConfig->toArray(), JSON_UNESCAPED_SLASHES); + if (!is_string($configJson)) { + $configJson = '{}'; + } + + $args = [ + 'docker', + 'run', + '-i', + '--rm', + '--name', $container, + '-p', "127.0.0.1:$port:" . self::INSTALLER_WEB_PORT_INTERNAL, + '--volume', '/var/run/docker.sock:/var/run/docker.sock', + '--volume', "$volumePath:/usr/src/code:rw", + ]; + $args[] = '-e'; + $args[] = 'APPWRITE_INSTALLER_CONFIG=' . $configJson; + $args[] = '--entrypoint=' . $entrypoint; + $args[] = $image; + + $escaped = array_map('escapeshellarg', $args); + $command = implode(' ', $escaped); + passthru($command, $status); + exit($status); } } + +/** + * Run server only on direct execution to avoid Swoole exit exception during autoload. + * + * @return bool + */ +function shouldRunInstallerServer(): bool +{ + return PHP_SAPI === 'cli-server' || (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__); +} + +if (shouldRunInstallerServer()) { + $server = new Server(); + $server->run(); +} diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 9e09c1f25a..a06be13663 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -17,7 +17,12 @@ use Utopia\Validator\Text; class Install extends Action { private const int INSTALL_STEP_DELAY_SECONDS = 2; + private const int WEB_SERVER_CHECK_ATTEMPTS = 10; + private const int WEB_SERVER_CHECK_DELAY_SECONDS = 1; + protected string $hostPath = ''; + protected ?bool $isLocalInstall = null; + protected ?array $installerConfig = null; protected string $path = '/usr/src/code/appwrite'; public static function getName(): string @@ -36,7 +41,6 @@ class Install extends Action ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) ->callback($this->action(...)); - } public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void @@ -55,7 +59,8 @@ class Install extends Action Console::success('Starting Appwrite installation...'); - $isLocalInstall = !empty(getenv('APPWRITE_INSTALLER_LOCAL')); + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); // Create directory with write permissions if (!\file_exists(\dirname($this->path))) { @@ -66,15 +71,14 @@ class Install extends Action } // Check for existing installation - $data = @file_get_contents($this->path . '/docker-compose.yml'); - $existingInstallation = $data !== false; + $data = $this->readExistingCompose(); + $existingInstallation = $data !== ''; if ($existingInstallation) { $time = \time(); - if (!$isLocalInstall) { - Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup'); - file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data); - } + $composeFileName = $this->getComposeFileName(); + Console::info('Compose file found, creating backup: ' . $composeFileName . '.' . $time . '.backup'); + file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); $oldVersion = $appwrite?->getImageVersion(); @@ -108,7 +112,7 @@ class Install extends Action } } - $envData = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName()); if ($envData !== false) { if (!$isLocalInstall) { @@ -239,45 +243,49 @@ class Install extends Action protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void { - $port = getenv('APPWRITE_INSTALLER_LOCAL') + $host = '0.0.0.0'; + $port = $this->isLocalInstall() ? InstallerServer::INSTALLER_WEB_PORT_INTERNAL : InstallerServer::INSTALLER_WEB_PORT; - $host = '0.0.0.0'; - // Create a router script for handling requests - $routerScript = \sys_get_temp_dir() . '/appwrite-installer-router.php'; - $this->createRouterScript($routerScript, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, $isUpgrade, $lockedDatabase); + $this->setInstallerConfig([ + 'defaultHttpPort' => $defaultHTTPPort, + 'defaultHttpsPort' => $defaultHTTPSPort, + 'organization' => $organization, + 'image' => $image, + 'noStart' => $noStart, + 'vars' => $vars, + 'isUpgrade' => $isUpgrade, + 'lockedDatabase' => $lockedDatabase, + 'isLocal' => $this->isLocalInstall(), + 'hostPath' => $this->hostPath ?: null, + ]); + $routerScript = dirname(__DIR__) . '/Installer/Server.php'; + $docroot = $this->buildFromProjectPath('/app/views/install'); // Start PHP built-in server in background $command = \sprintf( - 'php -S %s:%d %s >/dev/null 2>&1 & echo $!', + 'php -S %s:%d -t %s %s 2>&1 & echo $!', $host, $port, + \escapeshellarg($docroot), \escapeshellarg($routerScript) ); - - // Start the server $output = []; - $exitCode = 0; - \exec($command, $output, $exitCode); + \exec($command, $output); $pid = isset($output[0]) ? (int) $output[0] : 0; - \register_shutdown_function(function () use ($routerScript, $pid) { - if (\file_exists($routerScript)) { - \unlink($routerScript); - } + \register_shutdown_function(function () use ($pid) { if ($pid > 0 && \function_exists('posix_kill')) { @\posix_kill($pid, SIGTERM); } }); \sleep(3); - // Check if the server actually started - $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); - if ($handle === false) { - Console::exit(1); + if (!$this->waitForWebServer($port)) { + Console::warning('Web installer did not respond in time. Please refresh the browser.'); + return; } - \fclose($handle); // Wait for the server process to finish while (true) { @@ -288,37 +296,6 @@ class Install extends Action \fclose($handle); \sleep(1); } - - // Cleanup - if (\file_exists($routerScript)) { - \unlink($routerScript); - } - } - - private function createRouterScript(string $path, string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void - { - $installPhpPath = __FILE__; - $appViewsPath = __DIR__ . '/../../../../app/views/install'; - $publicPath = __DIR__ . '/../../../../public'; - $vendorPath = __DIR__ . '/../../../../vendor/autoload.php'; - $appwritePath = __DIR__ . '/../../../../app/init.php'; - - InstallerServer::writeRouterScript( - $path, - $installPhpPath, - $appViewsPath, - $publicPath, - $vendorPath, - $appwritePath, - $defaultHTTPPort, - $defaultHTTPSPort, - $organization, - $image, - $noStart, - $vars, - $isUpgrade, - $lockedDatabase - ); } public function prepareEnvironmentVariables(array $userInput, array $vars): array @@ -329,12 +306,17 @@ class Install extends Action // Start with all defaults foreach ($vars as $var) { + $default = $var['default'] ?? null; + $hasDefault = $default !== null && $default !== ''; if (!empty($var['filter']) && $var['filter'] === 'token') { - $input[$var['name']] = $token->generate(); + $input[$var['name']] = $hasDefault ? $default : $token->generate(); } elseif (!empty($var['filter']) && $var['filter'] === 'password') { - $input[$var['name']] = $password->generate(); + /*;#+@:/?& broke DSNs locally */ + $input[$var['name']] = $hasDefault + ? $default + : $this->generatePasswordValue($var['name'], $password); } else { - $input[$var['name']] = $var['default']; + $input[$var['name']] = $default; } } @@ -345,6 +327,15 @@ class Install extends Action } } + foreach ($input as $key => $value) { + if (!is_string($value)) { + continue; + } + if (str_contains($value, "\n") || str_contains($value, "\r")) { + throw new \InvalidArgumentException('Invalid value for ' . $key); + } + } + // Set database-specific connection details $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; if ($database === 'mongodb') { @@ -358,25 +349,46 @@ class Install extends Action return $input; } - private function reportProgress(?callable $progress, string $step, string $status, string $message, array $details = []): void + private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void { if (!$progress) { return; } + if ($messageOverride !== null) { + $message = $messageOverride; + } else { + $key = $status === InstallerServer::STATUS_COMPLETED ? 'done' : 'start'; + $message = $messages[$step][$key] ?? null; + if ($message === null) { + return; + } + } + try { $progress($step, $status, $message, $details); } catch (\Throwable $e) { } - } - - private function delayProgress(?callable $progress, string $status): void - { - if ($progress && $status !== 'error') { + if ($status === InstallerServer::STATUS_IN_PROGRESS) { sleep(self::INSTALL_STEP_DELAY_SECONDS); } } + private function setInstallerConfig(array $config): void + { + $json = json_encode($config, JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + return; + } + + putenv('APPWRITE_INSTALLER_CONFIG=' . $json); + $path = InstallerServer::INSTALLER_CONFIG_FILE; + if (@file_put_contents($path, $json) === false) { + return; + } + @chmod($path, 0600); + } + public function performInstallation( string $httpPort, string $httpsPort, @@ -388,37 +400,53 @@ class Install extends Action ?string $resumeFromStep = null, bool $isUpgrade = false ): void { - $isCLI = php_sapi_name() === 'cli'; - $useExistingConfig = !empty(getenv('APPWRITE_INSTALLER_LOCAL')) - && file_exists($this->path . '/docker-compose.yml') - && file_exists($this->path . '/.env'); + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, false); - if (getenv('APPWRITE_INSTALLER_LOCAL')) { + $isCLI = php_sapi_name() === 'cli'; + if ($isLocalInstall) { + $useExistingConfig = false; + } else { + $useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName()) + && file_exists($this->path . '/' . $this->getEnvFileName()); + } + + if ($isLocalInstall) { $organization = 'appwrite'; $image = 'appwrite'; } - $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml'); - $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml'); + $templateForEnv = new View($this->buildFromProjectPath('/app/views/install/env.phtml')); + $templateForCompose = new View($this->buildFromProjectPath('/app/views/install/compose.phtml')); $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; $version = \defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest'; - if (getenv('APPWRITE_INSTALLER_LOCAL')) { + if ($isLocalInstall) { $version = 'local'; } + $assistantKey = (string) ($input['_APP_ASSISTANT_OPENAI_API_KEY'] ?? ''); + $enableAssistant = trim($assistantKey) !== ''; + $templateForCompose ->setParam('httpPort', $httpPort) ->setParam('httpsPort', $httpsPort) ->setParam('version', $version) ->setParam('organization', $organization) ->setParam('image', $image) - ->setParam('database', $database); + ->setParam('database', $database) + ->setParam('hostPath', $this->hostPath) + ->setParam('enableAssistant', $enableAssistant); $templateForEnv->setParam('vars', $input); - $steps = ['docker-compose', 'env-vars', 'docker-containers']; + $steps = [ + InstallerServer::STEP_DOCKER_COMPOSE, + InstallerServer::STEP_ENV_VARS, + InstallerServer::STEP_DOCKER_CONTAINERS + ]; + $startIndex = 0; if ($resumeFromStep !== null) { $resumeIndex = array_search($resumeFromStep, $steps, true); @@ -429,144 +457,46 @@ class Install extends Action $currentStep = null; - $messages = $isUpgrade ? [ - 'config-files' => [ - 'start' => 'Updating configuration files...', - 'done' => 'Configuration files updated', - ], - 'docker-compose' => [ - 'start' => 'Updating Docker Compose file...', - 'done' => 'Docker Compose file updated', - ], - 'env-vars' => [ - 'start' => 'Updating environment variables...', - 'done' => 'Environment variables updated', - ], - 'docker-containers' => [ - 'start' => 'Restarting Docker containers...', - 'done' => 'Docker containers restarted', - ], - ] : [ - 'config-files' => [ - 'start' => 'Creating configuration files...', - 'done' => 'Configuration files created', - ], - 'docker-compose' => [ - 'start' => 'Generating Docker Compose file...', - 'done' => 'Docker Compose file generated', - ], - 'env-vars' => [ - 'start' => 'Configuring environment variables...', - 'done' => 'Environment variables configured', - ], - 'docker-containers' => [ - 'start' => 'Starting Docker containers...', - 'done' => 'Docker containers started', - ], - ]; + $messages = $this->buildStepMessages($isUpgrade); try { if ($startIndex <= 1) { - $this->reportProgress($progress, 'config-files', 'in-progress', $messages['config-files']['start']); - $this->delayProgress($progress, 'in-progress'); + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_IN_PROGRESS, $messages); } if ($startIndex <= 0) { - $currentStep = 'docker-compose'; - $this->reportProgress($progress, 'docker-compose', 'in-progress', $messages['docker-compose']['start']); - $this->delayProgress($progress, 'in-progress'); + $currentStep = InstallerServer::STEP_DOCKER_COMPOSE; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages); if (!$useExistingConfig) { - if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { - throw new \Exception('Failed to save Docker Compose file'); - } + $this->writeComposeFile($templateForCompose, $isLocalInstall); } - $this->reportProgress($progress, 'docker-compose', 'completed', $messages['docker-compose']['done']); + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages); } if ($startIndex <= 1) { - $currentStep = 'env-vars'; - $this->reportProgress($progress, 'env-vars', 'in-progress', $messages['env-vars']['start']); - $this->delayProgress($progress, 'in-progress'); + $currentStep = InstallerServer::STEP_ENV_VARS; + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages); if (!$useExistingConfig) { - if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - throw new \Exception('Failed to save environment variables file'); - } + $this->writeEnvFile($templateForEnv, $isLocalInstall); } - $this->reportProgress($progress, 'env-vars', 'completed', $messages['env-vars']['done']); - $this->reportProgress($progress, 'config-files', 'completed', $messages['config-files']['done']); + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_COMPLETED, $messages); + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages); } if ($database === 'mongodb' && !$useExistingConfig) { - $mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh'; - - if (file_exists($mongoEntrypoint)) { - copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); - } + $this->copyMongoEntrypointIfNeeded(); } if (!$noStart && $startIndex <= 2) { - $currentStep = 'docker-containers'; - $this->reportProgress($progress, 'docker-containers', 'in-progress', $messages['docker-containers']['start']); - $this->delayProgress($progress, 'in-progress'); - $envVars = getenv(); - if (!is_array($envVars)) { - $envVars = []; - } - if (!$useExistingConfig) { - foreach ($input as $key => $value) { - if ($value === null || $value === '') { - continue; - } - if (!preg_match('/^[A-Z0-9_]+$/', $key)) { - throw new \Exception('Invalid environment variable name'); - } - $envVars[$key] = (string) $value; - } - } + $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI); - if ($isCLI) { - Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); - } - - $command = [ - 'docker', - 'compose', - '--project-directory', - $this->path, - 'up', - '-d', - '--remove-orphans', - '--renew-anon-volumes' - ]; - $descriptorSpec = [ - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - $process = \proc_open($command, $descriptorSpec, $pipes, null, $envVars); - if (!is_resource($process)) { - throw new \Exception('Failed to start Docker Compose'); - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - if (is_resource($pipes[1])) { - fclose($pipes[1]); - } - if (is_resource($pipes[2])) { - fclose($pipes[2]); - } - $exit = \proc_close($process); - - if ($exit !== 0) { - $output = trim(($stderr ?: '') . ($stdout ?: '')); - $previous = $output !== '' ? new \RuntimeException($output) : null; - throw new \RuntimeException('Failed to start containers', 0, $previous); - } - - $this->reportProgress($progress, 'docker-containers', 'completed', $messages['docker-containers']['done']); + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); if ($isCLI) { Console::success('Appwrite installed successfully'); @@ -585,9 +515,244 @@ class Install extends Action if ($previous instanceof \Throwable && $previous->getMessage() !== '') { $details['output'] = $previous->getMessage(); } - $this->reportProgress($progress, $currentStep, 'error', $e->getMessage(), $details); + $this->updateProgress($progress, $currentStep, InstallerServer::STATUS_ERROR, $messages, $details, $e->getMessage()); } throw $e; } } + + private function buildStepMessages(bool $isUpgrade): array + { + $isUpgradeLabel = $isUpgrade ? 'updated' : 'created'; + $verbs = [ + InstallerServer::STEP_CONFIG_FILES => $isUpgrade ? 'Updating' : 'Creating', + InstallerServer::STEP_DOCKER_COMPOSE => $isUpgrade ? 'Updating' : 'Generating', + InstallerServer::STEP_ENV_VARS => $isUpgrade ? 'Updating' : 'Configuring', + InstallerServer::STEP_DOCKER_CONTAINERS => $isUpgrade ? 'Restarting' : 'Starting', + ]; + + return [ + InstallerServer::STEP_CONFIG_FILES => [ + 'start' => $verbs[InstallerServer::STEP_CONFIG_FILES] . ' configuration files...', + 'done' => 'Configuration files ' . $isUpgradeLabel, + ], + InstallerServer::STEP_DOCKER_COMPOSE => [ + 'start' => $verbs[InstallerServer::STEP_DOCKER_COMPOSE] . ' Docker Compose file...', + 'done' => 'Docker Compose file ' . $isUpgradeLabel, + ], + InstallerServer::STEP_ENV_VARS => [ + 'start' => $verbs[InstallerServer::STEP_ENV_VARS] . ' environment variables...', + 'done' => 'Environment variables ' . $isUpgradeLabel, + ], + InstallerServer::STEP_DOCKER_CONTAINERS => [ + 'start' => $verbs[InstallerServer::STEP_DOCKER_CONTAINERS] . ' Docker containers...', + 'done' => $isUpgrade ? 'Docker containers restarted' : 'Docker containers started', + ], + ]; + } + + private function writeComposeFile(View $template, bool $isLocalInstall): void + { + // Always use container path for file operations + // Use a separate file for web installer to avoid conflicts + $composeFileName = $this->getComposeFileName(); + $targetPath = $this->path . '/' . $composeFileName; + $renderedContent = $template->render(false); + + $result = @file_put_contents($targetPath, $renderedContent); + if ($result === false) { + $lastError = error_get_last(); + $errorMsg = $lastError ? $lastError['message'] : 'Unknown error'; + throw new \Exception('Failed to save Docker Compose file: ' . $errorMsg . ' (path: ' . $targetPath . ')'); + } + } + + private function writeEnvFile(View $template, bool $isLocalInstall): void + { + // Always use container path for file operations + // Use a separate env file for installer to avoid conflicts + $envFileName = $this->getEnvFileName(); + if (!\file_put_contents($this->path . '/' . $envFileName, $template->render(false))) { + throw new \Exception('Failed to save environment variables file'); + } + } + + private function copyMongoEntrypointIfNeeded(): void + { + $mongoEntrypoint = $this->buildFromProjectPath('/mongo-entrypoint.sh'); + + if (file_exists($mongoEntrypoint)) { + // Always use container path for file operations + copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); + } + } + + protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void + { + $env = ''; + if (!$useExistingConfig) { + foreach ($input as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (!preg_match('/^[A-Z0-9_]+$/', $key)) { + throw new \Exception('Invalid environment variable name'); + } + $env .= $key . '=' . \escapeshellarg((string) $value) . ' '; + } + } + + if ($isCLI) { + Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); + } + + // Docker compose runs inside container, so use container paths + // The compose file itself contains host paths for volume mounts + $composeFileName = $this->getComposeFileName(); + $composeFile = $this->path . '/' . $composeFileName; + + $command = [ + 'docker', + 'compose', + '-f', + $composeFile, + ]; + + if ($isLocalInstall) { + $command[] = '--project-name'; + $command[] = 'appwrite'; + } + + $command[] = '--project-directory'; + $command[] = $this->path; + $command[] = 'up'; + $command[] = '-d'; + $command[] = '--remove-orphans'; + $command[] = '--renew-anon-volumes'; + $escaped = array_map('escapeshellarg', $command); + $commandLine = $env . implode(' ', $escaped) . ' 2>&1'; + \exec($commandLine, $output, $exit); + + if ($exit !== 0) { + $message = trim(implode("\n", $output)); + $previous = $message !== '' ? new \RuntimeException($message) : null; + throw new \RuntimeException('Failed to start containers', 0, $previous); + } + if ($isLocalInstall && $isCLI && !empty($output)) { + Console::log(implode("\n", $output)); + } + } + + protected function isLocalInstall(): bool + { + $config = null; + if ($this->isLocalInstall === null) { + $config = $this->readInstallerConfig(); + $this->isLocalInstall = !empty($config['isLocal']); + } + + return $this->isLocalInstall; + } + + protected function readInstallerConfig(): array + { + if ($this->installerConfig !== null) { + return $this->installerConfig; + } + + $this->installerConfig = []; + $decodeConfig = static function (string $json): ?array { + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : null; + }; + + $json = getenv('APPWRITE_INSTALLER_CONFIG'); + $path = InstallerServer::INSTALLER_CONFIG_FILE; + $fileJson = file_exists($path) ? file_get_contents($path) : null; + + foreach ([$json, $fileJson] as $candidate) { + if (!is_string($candidate) || $candidate === '') { + continue; + } + + $decoded = $decodeConfig($candidate); + if ($decoded !== null) { + $this->installerConfig = $decoded; + return $this->installerConfig; + } + } + + return $this->installerConfig; + } + + protected function getInstallerHostPath(): string + { + $config = $this->readInstallerConfig(); + if (!empty($config['hostPath'])) { + return (string) $config['hostPath']; + } + + $cwd = getcwd(); + return $cwd !== false ? $cwd : '.'; + } + + protected function buildFromProjectPath(string $suffix): string + { + if ($suffix !== '' && $suffix[0] !== '/') { + $suffix = '/' . $suffix; + } + return dirname(__DIR__, 4) . $suffix; + } + + protected function applyLocalPaths(bool $isLocalInstall, bool $force = false): void + { + if (!$isLocalInstall) { + return; + } + if (!$force && $this->hostPath !== '') { + return; + } + $this->path = '/usr/src/code'; + $this->hostPath = $this->getInstallerHostPath(); + } + + protected function readExistingCompose(): string + { + $composeFile = $this->path . '/' . $this->getComposeFileName(); + $data = @file_get_contents($composeFile); + return !empty($data) ? $data : ''; + } + + protected function generatePasswordValue(string $varName, Password $password): string + { + $value = $password->generate(); + if (!\preg_match('/^_APP_DB_.*_PASS$/', $varName)) { + return $value; + } + + return rtrim(strtr(base64_encode(hash('sha256', $value, true)), '+/', '-_'), '='); + } + + protected function getComposeFileName(): string + { + return $this->isLocalInstall() ? 'docker-compose.web-installer.yml' : 'docker-compose.yml'; + } + + protected function getEnvFileName(): string + { + return $this->isLocalInstall() ? '.env.web-installer' : '.env'; + } + + private function waitForWebServer(int $port): bool + { + for ($attempt = 0; $attempt < self::WEB_SERVER_CHECK_ATTEMPTS; $attempt++) { + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle !== false) { + \fclose($handle); + return true; + } + \sleep(self::WEB_SERVER_CHECK_DELAY_SECONDS); + } + return false; + } } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index ed2970cf41..cddd3d0276 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Tasks; +use Appwrite\Docker\Env; use Utopia\CLI\Console; use Utopia\System\System; use Utopia\Validator\Boolean; @@ -33,20 +34,32 @@ class Upgrade extends Install public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Check for previous installation - $data = @file_get_contents($this->path . '/docker-compose.yml'); - if (empty($data)) { + if (empty($this->readExistingCompose())) { Console::error('Appwrite installation not found.'); Console::log('The command was not run in the parent folder of your appwrite installation.'); Console::log('Please navigate to the parent directory of the Appwrite installation and try again.'); Console::log(' parent_directory <= you run the command in this directory'); Console::log(' └── appwrite'); Console::log(' └── docker-compose.yml'); - Console::exit(1); + return; } - $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb'); - $this->lockedDatabase = $database; + $database = null; + $envPath = $this->path . '/' . $this->getEnvFileName(); + $envData = @file_get_contents($envPath); + if ($envData !== false) { + $env = new Env($envData); + $envVars = $env->list(); + $database = $envVars['_APP_DB_ADAPTER'] ?? null; + } + if (empty($database)) { + $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb'); + } + $this->lockedDatabase = (string) $database; parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, true); } From 80a5c24840f1dcc2983e5a15af4874e8b0681b88 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sat, 24 Jan 2026 16:32:01 +0530 Subject: [PATCH 07/46] bump: gitignore. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b07aed4b4a..02a71cda79 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ test-results # Web installer test files docker-compose.web-installer.yml .env.web-installer + +# backups when testing upgrade mode. +docker-compose.web-installer.yml.**.backup From eb8abcfe63d33a52f107fa593ace3bb8f7f68d57 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sat, 24 Jan 2026 16:35:14 +0530 Subject: [PATCH 08/46] fix: empty input considered as invalid. fix: referencing wrong file in upgrade task. --- src/Appwrite/Platform/Installer/HttpHandler.php | 2 +- src/Appwrite/Platform/Tasks/Upgrade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Installer/HttpHandler.php b/src/Appwrite/Platform/Installer/HttpHandler.php index d26ad6628f..dd11c81690 100644 --- a/src/Appwrite/Platform/Installer/HttpHandler.php +++ b/src/Appwrite/Platform/Installer/HttpHandler.php @@ -233,7 +233,7 @@ class HttpHandler $rawBody = file_get_contents('php://input'); $input = json_decode($rawBody, true); - if (!$input) { + if (!is_array($input)) { http_response_code(400); echo json_encode(['success' => false, 'message' => 'Invalid request']); return true; diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index cddd3d0276..f35ba1b59f 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -44,7 +44,7 @@ class Upgrade extends Install Console::log('Please navigate to the parent directory of the Appwrite installation and try again.'); Console::log(' parent_directory <= you run the command in this directory'); Console::log(' └── appwrite'); - Console::log(' └── docker-compose.yml'); + Console::log(' └── ' . $this->getComposeFileName()); return; } From 0eda9e6315ee736cf48a484d85910c995c995f2d Mon Sep 17 00:00:00 2001 From: Darshan Date: Sat, 24 Jan 2026 20:09:56 +0530 Subject: [PATCH 09/46] add: account creation. feat: mocking options. --- app/views/install/installer.phtml | 70 ++++- app/views/install/installer/css/styles.css | 280 +++++++++++++++++- app/views/install/installer/icons/eye-off.svg | 4 + app/views/install/installer/icons/eye.svg | 4 + app/views/install/installer/js/installer.js | 47 ++- .../install/installer/js/modules/context.js | 29 +- .../installer/js/modules/mock-controls.js | 97 ++++++ .../install/installer/js/modules/mock.js | 40 +++ .../install/installer/js/modules/progress.js | 109 +++++-- .../install/installer/js/modules/state.js | 12 +- .../installer/js/modules/validation.js | 6 + app/views/install/installer/js/steps.js | 103 ++++++- .../installer/templates/steps/step-2.phtml | 2 +- .../installer/templates/steps/step-3.phtml | 111 ++++--- .../installer/templates/steps/step-4.phtml | 99 ++++--- .../installer/templates/steps/step-5.phtml | 53 ++++ .../Platform/Installer/HttpHandler.php | 44 ++- .../Platform/Installer/Runtime/State.php | 2 +- src/Appwrite/Platform/Installer/Server.php | 9 +- src/Appwrite/Platform/Tasks/Install.php | 198 +++++++++++-- 20 files changed, 1101 insertions(+), 218 deletions(-) create mode 100644 app/views/install/installer/icons/eye-off.svg create mode 100644 app/views/install/installer/icons/eye.svg create mode 100644 app/views/install/installer/js/modules/mock-controls.js create mode 100644 app/views/install/installer/js/modules/mock.js create mode 100644 app/views/install/installer/templates/steps/step-5.phtml diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index db97dc7bdb..a469d2be9d 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -19,9 +19,9 @@ if ($installMode === 'mock' && $defaultEmailCertificates === '') { } $step = isset($_GET['step']) ? (int) $_GET['step'] : 1; -$step = max(1, min(4, $step)); -if ($isUpgrade && $step === 2) { - $step = 3; +$step = max(1, min(5, $step)); +if ($isUpgrade && ($step === 2 || $step === 3)) { + $step = 4; } $partialFile = __DIR__ . "/installer/templates/steps/step-{$step}.phtml"; @@ -34,7 +34,7 @@ if (isset($_GET['partial'])) { exit; } -$cardStep = min(3, $step); +$cardStep = min(4, $step); $stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml"; if (!is_file($stepFile)) { $stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml"; @@ -43,6 +43,8 @@ $cssVersion = @filemtime(__DIR__ . '/installer/css/styles.css') ?: time(); $tooltipsVersion = @filemtime(__DIR__ . '/installer/js/tooltips.js') ?: time(); $constantsVersion = @filemtime(__DIR__ . '/installer/js/constants.js') ?: time(); $stepsContextVersion = @filemtime(__DIR__ . '/installer/js/modules/context.js') ?: time(); +$mockModuleVersion = @filemtime(__DIR__ . '/installer/js/modules/mock.js') ?: time(); +$mockControlsVersion = @filemtime(__DIR__ . '/installer/js/modules/mock-controls.js') ?: time(); $stepsStateVersion = @filemtime(__DIR__ . '/installer/js/modules/state.js') ?: time(); $stepsValidationVersion = @filemtime(__DIR__ . '/installer/js/modules/validation.js') ?: time(); $stepsUiVersion = @filemtime(__DIR__ . '/installer/js/modules/ui.js') ?: time(); @@ -64,6 +66,10 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() + + + + @@ -127,6 +133,10 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() + + + +
- +
@@ -148,6 +158,56 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time()
+ + +
+
+
+

Mock settings

+ +
+

Adjust mock responses for this session.

+
+
+
+
+

Simulate session expiry

+

Show the toast error before install starts.

+
+ +
+
+
+

Simulate install error

+

Show a failed step and retry flow.

+
+ +
+
+
+

Simulate account error

+

Fail the account creation step.

+
+ +
+
+
+
+ +