From cbf0cc842df53dbfdf6907fb87ab9bb7363aee86 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 2 Dec 2025 23:33:21 +1300 Subject: [PATCH] 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. + }); +});