diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1421eeacc9..4bfe432c7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,31 @@ jobs: sarif_file: 'trivy-fs-results.sarif' category: 'trivy-source' + composer: + name: Checks / Composer + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Validate + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Audit + env: + COMPOSER_NO_AUDIT: 0 + run: composer audit + format: name: Checks / Format runs-on: ubuntu-latest @@ -96,15 +121,18 @@ jobs: - run: git checkout HEAD^2 if: github.event_name == 'pull_request' - - name: Validate composer.json and composer.lock - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer validate" + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs - name: Run Linter - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer install --profile --ignore-platform-reqs && composer lint" + run: composer lint analyze: name: Checks / Analyze @@ -113,15 +141,33 @@ jobs: - name: Check out the repo uses: actions/checkout@v6 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + - name: Run PHPStan - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer install --profile --ignore-platform-reqs && composer analyze" + run: composer analyze + + locale: + name: Checks / Locale + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' - name: Run Locale check - run: | - docker run --rm -v $PWD:/app node:24-alpine sh -c \ - "cd /app/.github/workflows/static-analysis/locale && node index.js" + run: node .github/workflows/static-analysis/locale/index.js matrix: name: Tests / Matrix diff --git a/.gitignore b/.gitignore index 6aac1bbbf4..d6e138a382 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ appwrite.config.json /app/config/specs/ /docs/examples/ .phpunit.cache +playwright-report +test-results +docker-compose.web-installer.yml +.env.web-installer +docker-compose.web-installer.yml.**.backup +tests/playwright/screenshots diff --git a/Dockerfile b/Dockerfile index 210c2bc3d9..7cb007c188 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.0.0 AS base +FROM appwrite/base:1.0.1 AS base LABEL maintainer="team@appwrite.io" @@ -121,5 +121,6 @@ RUN if [ "$DEBUG" = "true" ]; then \ fi EXPOSE 80 +EXPOSE 8080 CMD [ "php", "app/http.php" ] diff --git a/app/cli.php b/app/cli.php index ee134b9487..b8721320be 100644 --- a/app/cli.php +++ b/app/cli.php @@ -167,7 +167,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -188,7 +188,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -211,9 +211,8 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a $database = null; return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { - if ($database !== null && $project !== null && ! $project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); - + if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); return $database; } @@ -229,8 +228,8 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); // set tenant - if ($project !== null && ! $project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); + if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); } return $database; diff --git a/app/controllers/general.php b/app/controllers/general.php index f77aa3ec52..1a099c4bde 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -555,7 +555,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } if (!empty($deployment->getAttribute('startCommand', ''))) { - $startCommand = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + $startCommand = 'cd /usr/local/server/src/function/ && ' . str_replace(['"', '`', '$'], ['\\"', '\\`', '\\$'], $deployment->getAttribute('startCommand', '')); } $runtimeEntrypoint = match ($version) { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c8824d3708..acc9c2dc9a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -215,7 +215,7 @@ Http::init() ); } - if (! $dbKey) { + if (!$dbKey) { throw new Exception(Exception::USER_UNAUTHORIZED); } diff --git a/app/init/resources.php b/app/init/resources.php index 1bab4491a4..4ebe705e73 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -589,7 +589,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -848,7 +848,7 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -878,9 +878,8 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati $database = null; return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) { - if ($database !== null && $project !== null && ! $project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); - + if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); return $database; } @@ -896,8 +895,8 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); // set tenant - if ($project !== null && ! $project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); + if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); } return $database; diff --git a/app/realtime.php b/app/realtime.php index 5addb2a78f..19dbdf6da2 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -115,7 +115,7 @@ if (!function_exists('getProjectDB')) { if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index a62f6f7e8c..0f4df352bd 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -12,8 +12,12 @@ $version = $this->getParam('version', ''); $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); -$dbService = $this->getParam('database'); - +$dbService = $this->getParam('database', 'mongodb'); +$allowedDbServices = ['mariadb', 'mongodb', 'postgresql']; +if (!\in_array($dbService, $allowedDbServices, true)) { + $dbService = 'mongodb'; +} +$hostPath = rtrim($this->getParam('hostPath', ''), '/'); ?>services: traefik: image: traefik:3.6 @@ -63,6 +67,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 @@ -72,8 +79,10 @@ $dbService = $this->getParam('database'); - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy # - clamav environment: - _APP_ENV @@ -227,8 +236,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -258,8 +269,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -286,8 +299,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -316,8 +331,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy volumes: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw @@ -381,8 +398,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -409,8 +428,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy volumes: - appwrite-functions:/storage/functions:rw - appwrite-sites:/storage/sites:rw @@ -479,8 +500,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy volumes: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw @@ -518,9 +541,12 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - - - openruntimes-executor + redis: + condition: service_healthy + : + condition: service_healthy + openruntimes-executor: + condition: service_started environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -559,8 +585,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -598,8 +626,10 @@ $dbService = $this->getParam('database'); volumes: - appwrite-uploads:/storage/uploads:rw depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -652,7 +682,8 @@ $dbService = $this->getParam('database'); volumes: - appwrite-imports:/storage/imports:rw depends_on: - - + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -688,8 +719,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -730,8 +763,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -761,8 +796,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -791,8 +828,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -821,8 +860,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - - - redis + : + condition: service_healthy + redis: + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -848,8 +889,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - - - redis + : + condition: service_healthy + redis: + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -875,8 +918,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - - - redis + : + condition: service_healthy + redis: + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -966,7 +1011,6 @@ $dbService = $this->getParam('database'); - OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET - mariadb: image: mariadb:10.11 container_name: appwrite-mariadb @@ -982,6 +1026,12 @@ $dbService = $this->getParam('database'); - MYSQL_PASSWORD=${_APP_DB_PASS} - MARIADB_AUTO_UPGRADE=1 command: 'mysqld --innodb-flush-method=fsync' + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s @@ -1059,18 +1109,24 @@ $dbService = $this->getParam('database'); postgresql: - image: postgres:18 + image: appwrite/postgres:0.1.0 container_name: appwrite-postgresql restart: unless-stopped networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql/data:rw + - appwrite-postgresql:/var/lib/postgresql:rw environment: - POSTGRES_DB=${_APP_DB_SCHEMA} - POSTGRES_USER=${_APP_DB_USER} - POSTGRES_PASSWORD=${_APP_DB_PASS} command: "postgres" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s @@ -1088,6 +1144,12 @@ $dbService = $this->getParam('database'); - appwrite volumes: - appwrite-redis:/data:rw + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s # clamav: # image: appwrite/clamav:1.2.0 @@ -1114,6 +1176,7 @@ volumes: appwrite-mongodb: appwrite-mongodb-keyfile: + appwrite-mongodb-config: appwrite-redis: appwrite-cache: diff --git a/app/views/install/env.phtml b/app/views/install/env.phtml index c3ebb6f918..51bda7a6fb 100644 --- a/app/views/install/env.phtml +++ b/app/views/install/env.phtml @@ -3,6 +3,10 @@ $vars = $this->getParam('vars'); foreach ($vars as $key => $value) { - echo $key.'='.$value."\n"; + if ($value === null || $value === '') { + echo $key . "=\n"; + } else { + echo $key . '="' . addcslashes((string) $value, '"\\$`') . '"' . "\n"; + } } ?> \ No newline at end of file diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml new file mode 100644 index 0000000000..3cf2b77aa9 --- /dev/null +++ b/app/views/install/installer.phtml @@ -0,0 +1,145 @@ + + + + + + + <?php echo $isUpgrade ? 'Appwrite Update' : '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..4601cfcb67 --- /dev/null +++ b/app/views/install/installer/css/styles.css @@ -0,0 +1,1743 @@ +/* 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-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 */ + --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-error: var(--web-red-700); + --fgcolor-on-error: var(--neutral-0); + + /* 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); + --overlay-scrim: rgba(25, 25, 28, 0.8); + + /* 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: 0; + --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-xs: 6px; + --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); + + /* Animation vars */ + --spinner-rotation: 0deg; + + /* 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); +} + +.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: + 0 2px 12px 0 rgba(0, 0, 0, 0.02), + 0 6px 8px 0 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); +} + +.is-hidden { + display: none !important; +} + +@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; +} + +body { + margin: 0; + color: var(--fgcolor-neutral-primary); + background: var(--bgcolor-neutral-default); + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; +} + +.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='5'] .installer-backdrop { + opacity: 1; +} + +.installer-gradients { + top: 34%; + left: 17%; + width: 409px; + height: 194px; + opacity: 0.32; + position: absolute; + pointer-events: none; + transform: translateY(-50%) rotate(-25deg); +} + +.installer-blob { + position: absolute; + left: 0; + top: 0; + --blob-x: 0; + --blob-y: 0; + transform: translate(var(--blob-x), var(--blob-y)) scale(1); + transform-origin: center; + animation: none; +} + +.installer-blob.blob-one { + --blob-x: 268.3px; + --blob-y: 108.3px; +} + +.installer-blob.blob-two { + --blob-x: 101.3px; + --blob-y: 101.3px; +} + +.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='5'] .installer-card { + opacity: 0; + pointer-events: none; +} + +.installer-page[data-install-locked='true'] .step-indicators { + opacity: 0.4; +} + +.selector-card.is-disabled { + cursor: default; + pointer-events: auto; +} + +.selector-card.is-disabled .selector-content, +.selector-card.is-disabled .selector-icon { + opacity: 0.4; +} + +.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='5'] .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 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.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 0.2s ease, + transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform, opacity; +} + +.install-row-main { + display: flex; + align-items: center; + gap: var(--gap-s); + height: 44px; + padding: 0 var(--space-6); + transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), + opacity 0.2s ease, + transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-row-label { + display: flex; + align-items: center; + gap: var(--gap-s); + min-width: 0; + min-height: 0; + padding-block-start: 0; +} + +.install-text { + min-width: 0; + display: inline-flex; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.install-text.is-enter { + opacity: 0; + transform: translateY(10px); +} + +.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(-20px); +} + +.install-row.is-entering .install-row-main { + height: 0; + opacity: 0; + transform: translateY(-20px); +} + +.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 200ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-icon-error { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1); + color: var(--fgcolor-error); +} + +.install-icon-check { + opacity: 0; +} + +.install-icon-spinner svg { + animation: none; + transform: rotate(var(--spinner-rotation)); +} + +.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; +} + +/* Keep the checkmark subtle: rely on container fade only (no stroke drawing). */ + +.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), monospace; + 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; + flex-direction: row; + gap: var(--gap-m); +} + +.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), sans-serif; + 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-brand), var(--sans-fallbacks), sans-serif; + 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-brand), var(--sans-fallbacks), sans-serif; + 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), sans-serif; + 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), sans-serif; + 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), sans-serif; + 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), sans-serif; + 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); + 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-icon { + display: flex; + flex-shrink: 0; + align-items: center; +} + +.field-error-icon svg { + width: 1rem; + height: 1rem; +} + +.field-error.is-visible { + opacity: 1; + max-height: 64px; +} + +.field-helper { + display: flex; + align-items: flex-start; + gap: var(--space-3); + color: var(--fgcolor-neutral-secondary); +} + +.field-helper-icon { + display: flex; + flex-shrink: 0; + align-items: center; +} + +.field-helper-icon svg { + width: 1rem; + height: 1rem; +} + +.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; + overflow: visible; +} + +.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.has-tooltip { + overflow: visible; +} + +.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.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); +} + +.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-neutral-default); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-neutral-strong); + outline-offset: -1px; + --alert-primary-color: var(--fgcolor-neutral-secondary); +} + +.inline-alert--warning { + background: var(--bgcolor-warning-weaker); + outline-color: var(--border-warning-weak); + --alert-primary-color: var(--fgcolor-warning); +} + +.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(--alert-primary-color); + 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.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; + 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; + border-radius: var(--border-radius-xs); +} + +.input-icon-button svg { + width: 16px; + height: 16px; + display: block; +} + +.input-icon-button:hover { + background: var(--overlay-neutral-hover); +} + +.input-icon-button:active { + background: var(--overlay-neutral-pressed); +} + +.password-toggle-icon { + display: inline-flex; +} + +.password-toggle [data-password-icon="hide"] { + display: none; +} + +.password-toggle.is-visible [data-password-icon="show"] { + display: none; +} + +.password-toggle.is-visible [data-password-icon="hide"] { + display: inline-flex; +} + +.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/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/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/eye-off.svg b/app/views/install/installer/icons/eye-off.svg new file mode 100644 index 0000000000..9c0b0a063b --- /dev/null +++ b/app/views/install/installer/icons/eye-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/install/installer/icons/eye.svg b/app/views/install/installer/icons/eye.svg new file mode 100644 index 0000000000..897b425bf3 --- /dev/null +++ b/app/views/install/installer/icons/eye.svg @@ -0,0 +1,4 @@ + + + + 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/postgresql.svg b/app/views/install/installer/icons/postgresql.svg new file mode 100644 index 0000000000..f891b75c37 --- /dev/null +++ b/app/views/install/installer/icons/postgresql.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..940c0bc780 --- /dev/null +++ b/app/views/install/installer/js/constants.js @@ -0,0 +1,11 @@ +(() => { + window.InstallerConstants = Object.freeze({ + stepTransitionMs: 260, + errorClearMs: 180, + installPollIntervalMs: 4000, + installFallbackDelayMs: 12000, + redirectDelayMs: 2500, + progressTransitionDelayMs: 320, + progressCompleteDelayMs: 140, + }); +})(); diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js new file mode 100644 index 0000000000..463b7f6221 --- /dev/null +++ b/app/views/install/installer/js/installer.js @@ -0,0 +1,450 @@ +(() => { + 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 { validateInstallRequest } = window.InstallerStepsProgress || {}; + + const isUpgrade = document.body?.dataset.upgrade === 'true'; + const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5]; + const cardSteps = stepFlow.filter((step) => step !== 5); + + 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 === 5) { + config[step] = { back: { target: null }, next: { target: null } }; + return; + } + const prev = stepFlow[index - 1] ?? null; + const next = stepFlow[index + 1] ?? null; + const label = next === 5 ? (isUpgrade ? 'Update' : '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(5, 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 === 5) { + 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 !== 5) { + loadStep(5, true); + return; + } + if (isTransitioning) { + pendingStep = targetStep; + pendingPushState = pendingPushState || pushState; + return; + } + loadStep(targetStep, pushState); + }; + + document.addEventListener('click', async (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 (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + } + if (isInstallLocked() && Number(target) !== 5) { + requestStep(5, true); + return; + } + requestStep(target, true); + }); + + window.addEventListener('popstate', (event) => { + const step = event.state?.step || getStepFromUrl(); + if (isInstallLocked() && Number(step) !== 5) { + requestStep(5, false); + return; + } + requestStep(step, false); + }); + + document.addEventListener('DOMContentLoaded', () => { + let step = getStepFromUrl(); + if (isInstallLocked() && step !== 5) { + const url = buildStepUrl(5); + window.history.replaceState({ step: 5 }, '', url.toString()); + step = 5; + } 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 === 5 && 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/modules/context.js b/app/views/install/installer/js/modules/context.js new file mode 100644 index 0000000000..bc08a8c8df --- /dev/null +++ b/app/views/install/installer/js/modules/context.js @@ -0,0 +1,105 @@ +(() => { + const getBodyDataset = () => document.body?.dataset ?? {}; + const isUpgradeMode = () => getBodyDataset().upgrade === 'true'; + const getLockedDatabase = () => getBodyDataset().lockedDatabase || ''; + + const STEP_IDS = Object.freeze({ + CONFIG_FILES: 'config-files', + DOCKER_COMPOSE: 'docker-compose', + ENV_VARS: 'env-vars', + DOCKER_CONTAINERS: 'docker-containers', + ACCOUNT_SETUP: 'account-setup' + }); + + 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' + }, + { + id: STEP_IDS.ACCOUNT_SETUP, + inProgress: 'Creating Appwrite account...', + done: 'Appwrite account created (redirecting...)' + } + ]); + + 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, + 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(5, numeric)); + }; + + window.InstallerStepsContext = Object.freeze({ + getBodyDataset, + isUpgradeMode, + getLockedDatabase, + 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..a6e784ada9 --- /dev/null +++ b/app/views/install/installer/js/modules/progress.js @@ -0,0 +1,899 @@ +(() => { + const { + INSTALLATION_STEPS, + TIMINGS, + getBodyDataset, + isUpgradeMode, + 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 () => { + 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; + } + stopSyncedSpinnerRotation(); + 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) { + if (text.textContent !== label) { + text.classList.remove('is-enter'); + text.textContent = label; + text.classList.add('is-enter'); + requestAnimationFrame(() => { + text.classList.remove('is-enter'); + }); + } + } + + // Show/hide "Navigate to Console" button for account setup errors + const consoleBtn = row.querySelector('[data-install-console]'); + if (consoleBtn) { + const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR; + consoleBtn.classList.toggle('is-hidden', !shouldShow); + } + }; + + 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: '' }; + }; + + let spinnerAnimationFrame = null; + const stopSyncedSpinnerRotation = () => { + if (spinnerAnimationFrame) { + cancelAnimationFrame(spinnerAnimationFrame); + spinnerAnimationFrame = null; + } + }; + + const startSyncedSpinnerRotation = (container) => { + stopSyncedSpinnerRotation(); + if (!container) return; + let startTime = null; + const animate = (timestamp) => { + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + const rotation = ((elapsed / 1000) * 360 * 1.5) % 360; + container.style.setProperty('--spinner-rotation', `${rotation}deg`); + spinnerAnimationFrame = requestAnimationFrame(animate); + }; + spinnerAnimationFrame = requestAnimationFrame(animate); + }; + + 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 = `${host}:${port}`; + } + return `${protocol}://${host}`; + }; + + const redirectToApp = () => { + const url = buildRedirectUrl(); + if (!url) return; + // Fire-and-forget: tell the installer server it can shut down + fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {}); + window.location.href = url; + }; + + const notifyInstallComplete = (installId, session) => { + if (!installId) return Promise.resolve(); + const payload = { installId }; + const sessionSecret = session?.sessionSecret || session?.secret; + const sessionId = session?.sessionId || session?.id; + const sessionExpire = session?.sessionExpire || session?.expire; + if (sessionSecret) { + payload.sessionSecret = sessionSecret; + } + if (sessionId) { + payload.sessionId = sessionId; + } + if (sessionExpire) { + payload.sessionExpire = sessionExpire; + } + return fetch('/install/complete', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify(payload) + }).catch(() => {}); + }; + + const buildInstallPayload = (installId) => { + const normalizedSecret = (formState?.opensslKey || '').trim(); + if (!normalizedSecret && generateSecretKey && !isUpgradeMode?.()) { + 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(); + const normalizedAccountEmail = (formState?.accountEmail || '').trim(); + const normalizedAccountPassword = (formState?.accountPassword || '').trim(); + + return { + installId, + httpPort: normalizedHttpPort, + httpsPort: normalizedHttpsPort, + database: formState?.database || 'mongodb', + appDomain: normalizedDomain, + emailCertificates: normalizedEmail, + opensslKey: (formState?.opensslKey || '').trim(), + assistantOpenAIKey: normalizedAssistantKey, + accountEmail: normalizedAccountEmail, + accountPassword: normalizedAccountPassword + }; + }; + + const fetchInstallStatus = async (installId) => { + 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 initStep5 = (root) => { + if (!root) return; + + 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; + startSyncedSpinnerRotation(list); + + list.innerHTML = ''; + const rowsById = new Map(); + const progressState = new Map(); + 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; + } + + 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); + 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) { + progressState.set(previousStep.id, { + status: STATUS.COMPLETED, + message: previousStep.done, + details: previousState.details + }); + } + } + } + } + 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 checkAllCompleted = () => { + const allDone = INSTALLATION_STEPS.every((step) => { + const state = progressState.get(step.id); + return state && state.status === STATUS.COMPLETED; + }); + if (!allDone) return; + const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); + const sessionDetails = accountState?.details; + finalizeInstall(); + notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { + setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); + }); + }; + + const startPolling = () => { + if (!activeInstall || activeInstall.pollTimer) return; + activeInstall.pollTimer = setInterval(async () => { + if (!activeInstall || activeInstall.completed) return; + const snapshot = await fetchInstallStatus(activeInstall.installId); + if (snapshot) { + applySnapshot(snapshot); + checkAllCompleted(); + } + }, TIMINGS?.installPollInterval ?? 0); + }; + + const scheduleFallback = () => { + 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); + } + stopSyncedSpinnerRotation(); + 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; + } + setInstallLock?.(installId, payload); + setUnloadGuard(true); + + try { + 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) { + // Mark every step as completed (preserving details + // from earlier progress events, e.g. session info). + INSTALLATION_STEPS.forEach((step) => { + const existing = progressState.get(step.id); + if (!existing || (existing.status !== STATUS.COMPLETED && existing.status !== STATUS.ERROR)) { + progressState.set(step.id, { + status: STATUS.COMPLETED, + message: step.done, + details: existing?.details + }); + } + }); + renderProgress(); + + // If any step ended in error (e.g. account creation + // failed), stay on the progress screen so the user can + // see the error and choose to retry or navigate to the + // console manually — don't auto-redirect. + const hasErrors = INSTALLATION_STEPS.some((step) => { + const state = progressState.get(step.id); + return state && state.status === STATUS.ERROR; + }); + + if (hasErrors) { + finalizeInstall(); + return; + } + + const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); + const sessionDetails = accountState?.details; + finalizeInstall(); + notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { + 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) { + // Stream ended without a "done" event (e.g. browser + // throttled the background tab). Check if we're done. + checkAllCompleted(); + if (!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 }); + }; + + list.addEventListener('click', (event) => { + const consoleButton = event.target.closest('[data-install-console]'); + const retryButton = event.target.closest('[data-install-retry]'); + + if (consoleButton) { + redirectToApp(); + return; + } + + if (retryButton) { + const row = retryButton.closest('.install-row'); + const stepId = row?.dataset.step; + retryInstallStep(stepId); + } + }); + + // When the user switches back to this tab, check if installation + // finished while the tab was in the background. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && activeInstall && !activeInstall.completed) { + checkAllCompleted(); + } + }); + + 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 = { + initStep5, + 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..9fcf9969a8 --- /dev/null +++ b/app/views/install/installer/js/modules/state.js @@ -0,0 +1,158 @@ +(() => { + const { + getBodyDataset, + isUpgradeMode, + getLockedDatabase + } = 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, + accountEmail: null, + accountPassword: 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 sanitizedPayload = payload ? { ...payload } : null; + if (sanitizedPayload) { + delete sanitizedPayload.opensslKey; + delete sanitizedPayload.accountPassword; + delete sanitizedPayload.assistantOpenAIKey; + } + const lock = { + installId, + payload: sanitizedPayload, + 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 = () => { + 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('accountEmail', payload.accountEmail); + }; + + 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..bde4cb7c44 --- /dev/null +++ b/app/views/install/installer/js/modules/ui.js @@ -0,0 +1,281 @@ +(() => { + const { TIMINGS } = window.InstallerStepsContext || {}; + const { formState } = window.InstallerStepsState || {}; + + const clearFieldErrors = (root) => { + if (!root) return; + root.querySelectorAll('.field-error').forEach((node) => { + node.classList.remove('is-visible'); + }); + root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => { + node.classList.remove('is-error'); + }); + root.querySelectorAll('.field-helper').forEach((helper) => { + helper.style.display = ''; + }); + }; + + 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 (hasSameMessage && alreadyVisible) { + return; + } + + 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); + } + errorText = error?.querySelector('.field-error-text'); + } + if (errorText) { + errorText.textContent = message; + } + + if (!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 helper = group.querySelector('.field-helper'); + if (helper) { + helper.style.display = 'none'; + } + }; + + 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'); + } + input.classList.remove('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.remove('is-error'); + } + const helper = group?.querySelector('.field-helper'); + if (helper) { + helper.style.display = ''; + } + }; + input.addEventListener('input', handler); + input.addEventListener('change', handler); + }; + + const toDatabaseLabel = (value) => { + if (!value) return ''; + const lower = value.toLowerCase(); + if (lower === 'mariadb') return 'MariaDB'; + if (lower === 'postgresql') return 'PostgreSQL'; + return '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..13ab60ef4e --- /dev/null +++ b/app/views/install/installer/js/modules/validation.js @@ -0,0 +1,117 @@ +(() => { + 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 isValidPassword = (value) => { + if (!value) return false; + return value.length >= 8 && /\S/.test(value); + }; + + 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, + isValidPassword, + isValidHostnameInput, + extractHostname, + isLocalHost + }; +})(); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js new file mode 100644 index 0000000000..d8b0621761 --- /dev/null +++ b/app/views/install/installer/js/steps.js @@ -0,0 +1,435 @@ +(() => { + 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 { + INSTALLATION_STEPS, + clampStep, + isUpgradeMode + } = Context; + + const { + formState, + dispatchStateChange, + applyBodyDefaults, + applyLockPayload, + clearInstallLock, + clearInstallId, + isInstallLocked, + syncInstallLockFlag, + getInstallLock, + getLockedDatabase + } = State; + + const { + isValidEmail, + isValidPort, + isValidHostnameInput, + isValidPassword + } = Validation; + + const { + clearFieldErrors, + setFieldError, + bindErrorClear, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + } = UI; + + let reviewListener = null; + + 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 lockDatabaseSelection = (root, lockedDatabase) => { + if (lockedDatabase) { + 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); + } + }); + } + }; + + const bindDatabaseSelection = (root) => { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + radio.addEventListener('change', () => { + formState.database = radio.value; + updateDatabaseSelection?.(radio, root); + }); + }); + }; + + 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) => { + 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 initStep1 = (root) => { + if (!root) return; + 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); + Tooltips?.setupTooltipPortals?.(root); + }; + + 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; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep2State(root); + if (!isUpgradeMode?.() && (!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 hydrateStep3State = (root) => { + State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value); + State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value); + }; + + const applyStep3State = (root) => { + const email = root.querySelector('#account-email'); + if (email && formState.accountEmail) email.value = formState.accountEmail; + + const password = root.querySelector('#account-password'); + if (password && formState.accountPassword) password.value = formState.accountPassword; + }; + + const initStep3 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep3State(root); + applyStep3State(root); + + const email = root.querySelector('#account-email'); + const password = root.querySelector('#account-password'); + const passwordToggle = root.querySelector('[data-password-toggle="account-password"]'); + + bindInputToState(email, 'accountEmail'); + bindInputToState(password, 'accountPassword'); + + bindErrorClear?.(email); + bindErrorClear?.(password); + + if (password && passwordToggle) { + passwordToggle.addEventListener('click', () => { + const isVisible = passwordToggle.classList.toggle('is-visible'); + password.type = isVisible ? 'text' : 'password'; + passwordToggle.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password'); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const initStep4 = (root) => { + if (!root) return; + 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 initStep = (step, container) => { + if (!container) return; + const root = container.querySelector('.step-layout') || container; + const normalized = clampStep?.(step) ?? 1; + Tooltips?.cleanupTooltipPortals?.(); + if (normalized !== 4 && reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + reviewListener = null; + } + if (normalized !== 5) { + Progress.cleanupInstallFlow?.(); + } + if (normalized === 1) initStep1(root); + if (normalized === 2) initStep2(root); + if (normalized === 3) initStep3(root); + if (normalized === 4) initStep4(root); + if (normalized === 5) Progress.initStep5?.(root); + }; + + window.InstallerSteps = { + initStep1, + initStep2, + initStep3, + initStep4, + initStep5: Progress.initStep5, + installationSteps: INSTALLATION_STEPS || [], + isInstallLocked, + getInstallLock, + clearInstallLock, + initStep, + validateStep: (step, container) => { + const root = container?.querySelector('.step-layout') || container; + const normalized = clampStep?.(step) ?? 1; + 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; + } + } + + if (normalized === 3) { + clearFieldErrors?.(root); + let valid = true; + const email = root?.querySelector('#account-email'); + const password = root?.querySelector('#account-password'); + + if (!email || !email.value.trim()) { + setFieldError?.(email, 'This field is required'); + valid = false; + } else if (!isValidEmail?.(email.value.trim())) { + setFieldError?.(email, 'Please enter a valid email address'); + valid = false; + } + + const passwordValue = password?.value ?? ''; + if (!password || !/\S/.test(passwordValue)) { + setFieldError?.(password, 'This field is required'); + valid = false; + } else if (!isValidPassword?.(passwordValue)) { + setFieldError?.(password, 'Password must be at least 8 characters long'); + valid = false; + } + + return valid; + } + + return true; + } + }; +})(); 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 new file mode 100644 index 0000000000..ae7674c3bf --- /dev/null +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -0,0 +1,184 @@ + +
+
+
+

+

+ +

+
+ +
+
+ + +
+ +
+ +
+ + + + + +
+
+ +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+
+
+
+
+ +
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..9b7eed11b1 --- /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..8eaf0e044b --- /dev/null +++ b/app/views/install/installer/templates/steps/step-3.phtml @@ -0,0 +1,61 @@ + +
+
+
+

Create your account

+

+ Set up the email and password for your Appwrite account. You can use these + credentials to sign in later. +

+
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+ + + + Password must be at least 8 characters long +
+
+
+
+
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..07dc865257 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-4.phtml @@ -0,0 +1,76 @@ + 'MariaDB', + 'postgresql' => 'PostgreSQL', + default => 'MongoDB', +}; +$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing'; +$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning'; +?> +
+
+
+

+

+ +

+
+ +
+
+
+
+
+ +
+
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-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml new file mode 100644 index 0000000000..8fa810b259 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-5.phtml @@ -0,0 +1,53 @@ + +
+
+
+
+
+ +
+
+
+
+
+ + +
diff --git a/app/worker.php b/app/worker.php index db036b6a99..840231f16c 100644 --- a/app/worker.php +++ b/app/worker.php @@ -112,7 +112,7 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register, if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -152,7 +152,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -174,7 +174,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -196,9 +196,8 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza $database = null; return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { - if ($database !== null && $project !== null && ! $project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); - + if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); return $database; } @@ -213,9 +212,8 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER); - // set tenant - if ($project !== null && ! $project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); + if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); } return $database; diff --git a/composer.json b/composer.json index 057118645e..06ee153574 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,11 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", + "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "bench": "vendor/bin/phpbench run --report=benchmark", - "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G" + "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "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" }, "autoload": { "psr-4": { @@ -58,7 +61,7 @@ "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", - "utopia-php/database": "5.*", + "utopia-php/database": "dev-fix-collection-recreate as 5.3.15", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", @@ -91,8 +94,15 @@ "spomky-labs/otphp": "11.*", "webonyx/graphql-php": "14.11.*", "league/csv": "9.14.*", - "enshrined/svg-sanitize": "0.22.*" + "enshrined/svg-sanitize": "0.22.*", + "utopia-php/di": "0.1.0" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database" + } + ], "require-dev": { "ext-fileinfo": "*", "appwrite/sdk-generator": "*", @@ -102,8 +112,7 @@ "phpstan/phpstan": "^2.0", "textalk/websocket": "1.5.*", "czproject/git-php": "4.*", - "laravel/pint": "1.*", - "phpbench/phpbench": "1.*" + "laravel/pint": "1.*" }, "provide": { "ext-phpiredis": "*" diff --git a/composer.lock b/composer.lock index c9128f3a1f..314265f3e0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "392d5bd2621b3eb57f8b1bf38d9e4bc3", + "content-hash": "13579de3d747c541fdcce4f709df8e57", "packages": [ { "name": "adhocore/jwt", @@ -3606,16 +3606,16 @@ }, { "name": "utopia-php/cache", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "7068870c086a6aea16173563a26b93ef3e408439" + "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/7068870c086a6aea16173563a26b93ef3e408439", - "reference": "7068870c086a6aea16173563a26b93ef3e408439", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c", + "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c", "shasum": "" }, "require": { @@ -3652,9 +3652,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/1.0.0" + "source": "https://github.com/utopia-php/cache/tree/1.0.1" }, - "time": "2026-01-28T10:55:44+00:00" + "time": "2026-03-12T03:39:09+00:00" }, { "name": "utopia-php/cli", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.8", + "version": "dev-fix-collection-recreate", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255" + "reference": "5208630969dfdfbe8eda9c34c6b28ce711ece7c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/4920bb60afb98d4bd81f4d331765716ae1d40255", - "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255", + "url": "https://api.github.com/repos/utopia-php/database/zipball/5208630969dfdfbe8eda9c34c6b28ce711ece7c4", + "reference": "5208630969dfdfbe8eda9c34c6b28ce711ece7c4", "shasum": "" }, "require": { @@ -3888,7 +3888,38 @@ "Utopia\\Database\\": "src/Database" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Tests\\E2E\\": "tests/e2e", + "Tests\\Unit\\": "tests/unit" + } + }, + "scripts": { + "build": [ + "Composer\\Config::disableProcessTimeout", + "docker compose build" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "docker compose up -d" + ], + "test": [ + "Composer\\Config::disableProcessTimeout", + "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" + ], + "lint": [ + "php -d memory_limit=2G ./vendor/bin/pint --test" + ], + "format": [ + "php -d memory_limit=2G ./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G" + ], + "coverage": [ + "./vendor/bin/coverage-check ./tmp/clover.xml 90" + ] + }, "license": [ "MIT" ], @@ -3901,10 +3932,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.8" + "source": "https://github.com/utopia-php/database/tree/fix-collection-recreate", + "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-03-11T01:03:34+00:00" + "time": "2026-03-16T11:58:09+00:00" }, { "name": "utopia-php/detector", @@ -4058,16 +4089,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.2", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6" + "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6", - "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6", + "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6", "shasum": "" }, "require": { @@ -4114,9 +4145,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.2" + "source": "https://github.com/utopia-php/domains/tree/1.0.5" }, - "time": "2026-02-25T08:18:25+00:00" + "time": "2026-03-03T09:20:50+00:00" }, { "name": "utopia-php/dsn", @@ -4167,16 +4198,16 @@ }, { "name": "utopia-php/emails", - "version": "0.6.8", + "version": "0.6.9", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b" + "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b", - "reference": "25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", + "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", "shasum": "" }, "require": { @@ -4222,9 +4253,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.8" + "source": "https://github.com/utopia-php/emails/tree/0.6.9" }, - "time": "2026-02-09T12:31:56+00:00" + "time": "2026-03-14T13:52:56+00:00" }, { "name": "utopia-php/fetch", @@ -4572,16 +4603,16 @@ }, { "name": "utopia-php/mongo", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d" + "reference": "83dbcde768d5fb40241f5ca8aa5ed8ca140a7469" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/45bedf36c2c946ec7a0a3e59b9f12f772de0b01d", - "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/83dbcde768d5fb40241f5ca8aa5ed8ca140a7469", + "reference": "83dbcde768d5fb40241f5ca8aa5ed8ca140a7469", "shasum": "" }, "require": { @@ -4627,9 +4658,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.0" + "source": "https://github.com/utopia-php/mongo/tree/1.0.1" }, - "time": "2026-02-12T05:54:06+00:00" + "time": "2026-03-13T07:29:24+00:00" }, { "name": "utopia-php/platform", @@ -5059,16 +5090,16 @@ }, { "name": "utopia-php/system", - "version": "0.10.0", + "version": "0.10.1", "source": { "type": "git", "url": "https://github.com/utopia-php/system.git", - "reference": "6441a9c180958a373e5ddb330264dd638539dfdb" + "reference": "7c1669533bb9c285de19191270c8c1439161a78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/system/zipball/6441a9c180958a373e5ddb330264dd638539dfdb", - "reference": "6441a9c180958a373e5ddb330264dd638539dfdb", + "url": "https://api.github.com/repos/utopia-php/system/zipball/7c1669533bb9c285de19191270c8c1439161a78a", + "reference": "7c1669533bb9c285de19191270c8c1439161a78a", "shasum": "" }, "require": { @@ -5109,9 +5140,9 @@ ], "support": { "issues": "https://github.com/utopia-php/system/issues", - "source": "https://github.com/utopia-php/system/tree/0.10.0" + "source": "https://github.com/utopia-php/system/tree/0.10.1" }, - "time": "2025-10-15T19:12:00+00:00" + "time": "2026-03-15T21:07:41+00:00" }, { "name": "utopia-php/telemetry", @@ -5215,29 +5246,28 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.0", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14" + "reference": "5769679308bad498f2777547d48ab332166c4c0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/058049326e04a2a0c2f0ce8ad00c7e84825aba14", - "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/5769679308bad498f2777547d48ab332166c4c0b", + "reference": "5769679308bad498f2777547d48ab332166c4c0b", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", - "utopia-php/cache": "1.0.*", - "utopia-php/framework": "0.*.*", - "utopia-php/system": "0.10.*" + "utopia-php/cache": "1.0.*" }, "require-dev": { "laravel/pint": "1.*.*", "phpstan/phpstan": "1.*.*", - "phpunit/phpunit": "^9.4" + "phpunit/phpunit": "^9.4", + "utopia-php/system": "0.10.*" }, "type": "library", "autoload": { @@ -5258,9 +5288,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.0" + "source": "https://github.com/utopia-php/vcs/tree/2.0.2" }, - "time": "2026-02-25T11:36:45+00:00" + "time": "2026-03-13T15:25:16+00:00" }, { "name": "utopia-php/websocket", @@ -5438,16 +5468,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.1", + "version": "1.11.6", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb" + "reference": "f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb", - "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38", + "reference": "f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38", "shasum": "" }, "require": { @@ -5483,22 +5513,22 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.11.1" + "source": "https://github.com/appwrite/sdk-generator/tree/1.11.6" }, - "time": "2026-02-25T07:15:19+00:00" + "time": "2026-03-09T07:12:51+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.0", + "version": "v7.19.2", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" + "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", "shasum": "" }, "require": { @@ -5512,9 +5542,9 @@ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", "phpunit/php-file-iterator": "^6.0.1 || ^7", "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.9 || ^13", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", "sebastian/environment": "^8.0.3 || ^9", - "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/console": "^7.4.7 || ^8.0.7", "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { @@ -5522,11 +5552,11 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.4.0 || ^8.0.1" + "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -5566,7 +5596,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" }, "funding": [ { @@ -5578,7 +5608,7 @@ "type": "paypal" } ], - "time": "2026-02-06T10:53:26+00:00" + "time": "2026-03-09T14:33:17+00:00" }, { "name": "czproject/git-php", @@ -5644,160 +5674,6 @@ ], "time": "2025-11-10T07:24:07+00:00" }, - { - "name": "doctrine/annotations", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/doctrine/annotations.git", - "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/901c2ee5d26eb64ff43c47976e114bf00843acf7", - "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7", - "shasum": "" - }, - "require": { - "doctrine/lexer": "^2 || ^3", - "ext-tokenizer": "*", - "php": "^7.2 || ^8.0", - "psr/cache": "^1 || ^2 || ^3" - }, - "require-dev": { - "doctrine/cache": "^2.0", - "doctrine/coding-standard": "^10", - "phpstan/phpstan": "^1.10.28", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "symfony/cache": "^5.4 || ^6.4 || ^7", - "vimeo/psalm": "^4.30 || ^5.14" - }, - "suggest": { - "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Docblock Annotations Parser", - "homepage": "https://www.doctrine-project.org/projects/annotations.html", - "keywords": [ - "annotations", - "docblock", - "parser" - ], - "support": { - "issues": "https://github.com/doctrine/annotations/issues", - "source": "https://github.com/doctrine/annotations/tree/2.0.2" - }, - "abandoned": true, - "time": "2024-09-05T10:17:24+00:00" - }, - { - "name": "doctrine/lexer", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Lexer\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "https://www.doctrine-project.org/projects/lexer.html", - "keywords": [ - "annotations", - "docblock", - "lexer", - "parser", - "php" - ], - "support": { - "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", - "type": "tidelift" - } - ], - "time": "2024-02-05T11:56:58+00:00" - }, { "name": "fidry/cpu-core-counter", "version": "1.3.0", @@ -5921,16 +5797,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -5941,13 +5817,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -5984,7 +5861,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "matthiasmullie/minify", @@ -6345,155 +6222,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpbench/container", - "version": "2.2.3", - "source": { - "type": "git", - "url": "https://github.com/phpbench/container.git", - "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpbench/container/zipball/0c7b2d36c1ea53fe27302fb8873ded7172047196", - "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196", - "shasum": "" - }, - "require": { - "psr/container": "^1.0|^2.0", - "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "require-dev": { - "php-cs-fixer/shim": "^3.89", - "phpstan/phpstan": "^0.12.52", - "phpunit/phpunit": "^8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpBench\\DependencyInjection\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Leech", - "email": "daniel@dantleech.com" - } - ], - "description": "Simple, configurable, service container.", - "support": { - "issues": "https://github.com/phpbench/container/issues", - "source": "https://github.com/phpbench/container/tree/2.2.3" - }, - "time": "2025-11-06T09:05:13+00:00" - }, - { - "name": "phpbench/phpbench", - "version": "1.4.3", - "source": { - "type": "git", - "url": "https://github.com/phpbench/phpbench.git", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/b641dde59d969ea42eed70a39f9b51950bc96878", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878", - "shasum": "" - }, - "require": { - "doctrine/annotations": "^2.0", - "ext-dom": "*", - "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", - "ext-tokenizer": "*", - "php": "^8.1", - "phpbench/container": "^2.2", - "psr/log": "^1.1 || ^2.0 || ^3.0", - "seld/jsonlint": "^1.1", - "symfony/console": "^6.1 || ^7.0 || ^8.0", - "symfony/filesystem": "^6.1 || ^7.0 || ^8.0", - "symfony/finder": "^6.1 || ^7.0 || ^8.0", - "symfony/options-resolver": "^6.1 || ^7.0 || ^8.0", - "symfony/process": "^6.1 || ^7.0 || ^8.0", - "webmozart/glob": "^4.6" - }, - "require-dev": { - "dantleech/invoke": "^2.0", - "ergebnis/composer-normalize": "^2.39", - "jangregor/phpstan-prophecy": "^1.0", - "php-cs-fixer/shim": "^3.9", - "phpspec/prophecy": "^1.22", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.4 || ^11.0", - "rector/rector": "^1.2", - "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", - "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-xdebug": "For Xdebug profiling extension." - }, - "bin": [ - "bin/phpbench" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2-dev" - } - }, - "autoload": { - "files": [ - "lib/Report/Func/functions.php" - ], - "psr-4": { - "PhpBench\\": "lib/", - "PhpBench\\Extensions\\XDebug\\": "extensions/xdebug/lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Leech", - "email": "daniel@dantleech.com" - } - ], - "description": "PHP Benchmarking Framework", - "keywords": [ - "benchmarking", - "optimization", - "performance", - "profiling", - "testing" - ], - "support": { - "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.4.3" - }, - "funding": [ - { - "url": "https://github.com/dantleech", - "type": "github" - } - ], - "time": "2025-11-06T19:07:31+00:00" - }, { "name": "phpstan/phpstan", "version": "2.1.40", @@ -6999,55 +6727,6 @@ ], "time": "2026-02-18T12:38:40+00:00" }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, { "name": "sebastian/cli-parser", "version": "4.2.0", @@ -7336,16 +7015,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", "shasum": "" }, "require": { @@ -7388,7 +7067,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" }, "funding": [ { @@ -7408,7 +7087,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-03-15T07:05:40+00:00" }, { "name": "sebastian/exporter", @@ -7945,70 +7624,6 @@ ], "time": "2025-02-07T05:00:38+00:00" }, - { - "name": "seld/jsonlint", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "shasum": "" - }, - "require": { - "php": "^5.3 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" - }, - "bin": [ - "bin/jsonlint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Seld\\JsonLint\\": "src/Seld/JsonLint/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" - } - ], - "description": "JSON Linter", - "keywords": [ - "json", - "linter", - "parser", - "validator" - ], - "support": { - "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" - }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", - "type": "tidelift" - } - ], - "time": "2024-07-11T14:55:45+00:00" - }, { "name": "staabm/side-effects-detector", "version": "1.0.5", @@ -8095,16 +7710,16 @@ }, { "name": "symfony/console", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", "shasum": "" }, "require": { @@ -8161,7 +7776,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.7" }, "funding": [ { @@ -8181,216 +7796,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v8.0.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-12-01T09:13:36+00:00" - }, - { - "name": "symfony/finder", - "version": "v8.0.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "symfony/filesystem": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-26T15:08:38+00:00" - }, - { - "name": "symfony/options-resolver", - "version": "v8.0.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-11-12T15:55:31+00:00" + "time": "2026-03-06T14:06:22+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8789,16 +8195,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -8855,7 +8261,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -8875,7 +8281,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "textalk/websocket", @@ -9054,60 +8460,20 @@ } ], "time": "2024-11-07T12:36:22+00:00" - }, - { - "name": "webmozart/glob", - "version": "4.7.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/glob.git", - "reference": "8a2842112d6916e61e0e15e316465b611f3abc17" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/glob/zipball/8a2842112d6916e61e0e15e316465b611f3abc17", - "reference": "8a2842112d6916e61e0e15e316465b611f3abc17", - "shasum": "" - }, - "require": { - "php": "^7.3 || ^8.0.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5", - "symfony/filesystem": "^5.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.1-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Glob\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "A PHP implementation of Ant's glob.", - "support": { - "issues": "https://github.com/webmozarts/glob/issues", - "source": "https://github.com/webmozarts/glob/tree/4.7.0" - }, - "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-fix-collection-recreate", + "alias": "5.3.15", + "alias_normalized": "5.3.15.0" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/docker-compose.yml b/docker-compose.yml index c0b0560a7f..7d64dfa867 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1122,6 +1122,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DATABASE_SHARED_TABLES appwrite-task-scheduler-messages: entrypoint: schedule-messages @@ -1300,7 +1301,7 @@ services: networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql/data:rw + - appwrite-postgresql:/var/lib/postgresql:rw ports: - "5432:5432" environment: diff --git a/docs/sdks/python/GETTING_STARTED.md b/docs/sdks/python/GETTING_STARTED.md index 2732ef8483..09569d3eb0 100644 --- a/docs/sdks/python/GETTING_STARTED.md +++ b/docs/sdks/python/GETTING_STARTED.md @@ -20,10 +20,16 @@ client = Client() ### Make Your First Request Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section. +All service methods return typed Pydantic models, so you can access response fields as attributes: + ```python users = Users(client) -result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") +user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + +print(user.name) # "Walter O'Brien" +print(user.email) # "email@example.com" +print(user.id) # The generated user ID ``` ### Full Example @@ -43,7 +49,60 @@ client = Client() users = Users(client) -result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") +user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + +print(user.name) # Access fields as attributes +print(user.to_dict()) # Convert to dictionary if needed +``` + +### Type Safety with Models + +The Appwrite Python SDK provides type safety when working with database rows through generic methods. Methods like `get_row`, `list_rows`, and others accept a `model_type` parameter that allows you to specify your custom Pydantic model for full type safety. + +```python +from pydantic import BaseModel +from datetime import datetime +from typing import Optional +from appwrite.client import Client +from appwrite.services.tables_db import TablesDB + +# Define your custom model matching your table schema +class Post(BaseModel): + postId: int + authorId: int + title: str + content: str + createdAt: datetime + updatedAt: datetime + isPublished: bool + excerpt: Optional[str] = None + +client = Client() +# ... configure your client ... + +tables_db = TablesDB(client) + +# Fetch a single row with type safety +row = tables_db.get_row( + database_id="your-database-id", + table_id="your-table-id", + row_id="your-row-id", + model_type=Post # Pass your custom model type +) + +print(row.data.title) # Fully typed - IDE autocomplete works +print(row.data.postId) # int type, not Any +print(row.data.createdAt) # datetime type + +# Fetch multiple rows with type safety +result = tables_db.list_rows( + database_id="your-database-id", + table_id="your-table-id", + model_type=Post +) + +for row in result.rows: + print(f"{row.data.title} by {row.data.authorId}") ``` ### Error Handling @@ -52,7 +111,8 @@ The Appwrite Python SDK raises `AppwriteException` object with `message`, `code` ```python users = Users(client) try: - result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + print(user.name) except AppwriteException as e: print(e.message) ``` diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh old mode 100755 new mode 100644 diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index adc40d1294..0000000000 --- a/phpbench.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema":"vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.path": "tests", - "runner.file_pattern": "*Bench.php" -} \ No newline at end of file diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 99ff5018d3..0219836405 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -72,12 +72,6 @@ parameters: count: 1 path: app/cli.php - - - message: '#^Parameter \#1 \$name of class Utopia\\Queue\\Queue constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/cli.php - - message: '#^Parameter \#1 \$name of method Utopia\\DI\\Injection\:\:inject\(\) expects string, mixed given\.$#' identifier: argument.type @@ -102,12 +96,6 @@ parameters: count: 1 path: app/cli.php - - - message: '#^Parameter \#1 \$version of method Utopia\\Logger\\Log\:\:setVersion\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/cli.php - - message: '#^Parameter \#2 \$cache of class Utopia\\Database\\Database constructor expects Utopia\\Cache\\Cache, mixed given\.$#' identifier: argument.type @@ -126,12 +114,6 @@ parameters: count: 1 path: app/cli.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: app/cli.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -192,18 +174,6 @@ parameters: count: 14 path: app/config/frameworks.php - - - message: '#^Parameter \#1 \$haystack of function str_contains expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: app/config/platform.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 3 - path: app/config/platform.php - - message: '#^Parameter \#2 \$default of static method Utopia\\System\\System\:\:getEnv\(\) expects string\|null, int given\.$#' identifier: argument.type @@ -372,12 +342,6 @@ parameters: count: 1 path: app/config/templates/function.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/config/templates/function.php - - message: '#^Array has 2 duplicate keys with value ''outputDirectory'' \(''outputDirectory'', ''outputDirectory''\)\.$#' identifier: array.duplicateKey @@ -2658,12 +2622,6 @@ parameters: count: 12 path: app/controllers/api/projects.php - - - message: '#^Parameter \#1 \$name of method Utopia\\Locale\\Locale\:\:setFallback\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/controllers/api/projects.php - - message: '#^Parameter \#1 \$queries of static method Utopia\\Database\\Query\:\:parseQueries\(\) expects array\, array given\.$#' identifier: argument.type @@ -3174,12 +3132,6 @@ parameters: count: 1 path: app/controllers/general.php - - - message: '#^Binary operation "\." between ''cd /usr/local…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: app/controllers/general.php - - message: '#^Binary operation "\." between ''http\://''\|''https\://'' and mixed results in an error\.$#' identifier: binaryOp.invalid @@ -3540,24 +3492,12 @@ parameters: count: 1 path: app/controllers/general.php - - - message: '#^Parameter \#1 \$version of method Utopia\\Logger\\Log\:\:setVersion\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/controllers/general.php - - message: '#^Parameter \#2 \$array of function array_key_exists expects array, mixed given\.$#' identifier: argument.type count: 2 path: app/controllers/general.php - - - message: '#^Parameter \#2 \$default of method Appwrite\\Utopia\\Request\:\:getHeader\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: app/controllers/general.php - - message: '#^Parameter \#2 \$default of method Utopia\\DSN\\DSN\:\:getParam\(\) expects string, float given\.$#' identifier: argument.type @@ -3588,12 +3528,6 @@ parameters: count: 1 path: app/controllers/general.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: app/controllers/general.php - - message: '#^Parameter \#2 \$value of method Utopia\\Http\\Response\:\:addCookie\(\) expects string\|null, mixed given\.$#' identifier: argument.type @@ -3606,6 +3540,12 @@ parameters: count: 2 path: app/controllers/general.php + - + message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: app/controllers/general.php + - message: '#^Parameter \$body of method Executor\\Executor\:\:createExecution\(\) expects string\|null, string\|false\|null given\.$#' identifier: argument.type @@ -4698,12 +4638,6 @@ parameters: count: 1 path: app/http.php - - - message: '#^Parameter \#1 \$namespace of method Utopia\\Database\\Database\:\:setNamespace\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/http.php - - message: '#^Parameter \#1 \$pool of class Utopia\\Database\\Adapter\\Pool constructor expects Utopia\\Pools\\Pool\, Utopia\\Pools\\Pool\ given\.$#' identifier: argument.type @@ -4767,7 +4701,7 @@ parameters: - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' identifier: argument.type - count: 4 + count: 1 path: app/http.php - @@ -4783,7 +4717,7 @@ parameters: path: app/http.php - - message: '#^Parameter \$port of class Swoole\\Http\\Server constructor expects int, string\|null given\.$#' + message: '#^Parameter \$port of class Swoole\\Http\\Server constructor expects int, string given\.$#' identifier: argument.type count: 1 path: app/http.php @@ -5053,7 +4987,7 @@ parameters: path: app/init/registers.php - - message: '#^Binary operation "/" between string\|null and string\|null results in an error\.$#' + message: '#^Binary operation "/" between string and string results in an error\.$#' identifier: binaryOp.invalid count: 1 path: app/init/registers.php @@ -5070,6 +5004,12 @@ parameters: count: 1 path: app/init/registers.php + - + message: '#^Offset ''dsns'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: app/init/registers.php + - message: '#^Offset ''host'' might not exist on array\{key\: string\|null, projectId\: string, host\: non\-falsy\-string\}\|array\{key\: string\}\|array\{ticket\: string, host\: string\}\.$#' identifier: offsetAccess.notFound @@ -5095,7 +5035,7 @@ parameters: path: app/init/registers.php - - message: '#^Offset ''multiple'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string\|null, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' + message: '#^Offset ''multiple'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset count: 1 path: app/init/registers.php @@ -5113,7 +5053,7 @@ parameters: path: app/init/registers.php - - message: '#^Offset ''schemes'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string\|null, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' + message: '#^Offset ''schemes'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset count: 1 path: app/init/registers.php @@ -5131,7 +5071,7 @@ parameters: path: app/init/registers.php - - message: '#^Offset ''type'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string\|null, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' + message: '#^Offset ''type'' on array\{type\: ''cache'', dsns\: non\-falsy\-string, multiple\: true, schemes\: array\{''redis''\}\}\|array\{type\: ''consumer''\|''publisher''\|''pubsub'', dsns\: non\-falsy\-string, multiple\: false, schemes\: array\{''redis''\}\}\|array\{type\: ''database'', dsns\: string, multiple\: bool, schemes\: array\{''mariadb'', ''mongodb'', ''mysql'', ''postgresql''\}\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset count: 1 path: app/init/registers.php @@ -5142,18 +5082,6 @@ parameters: count: 1 path: app/init/registers.php - - - message: '#^Parameter \#1 \$address of method PHPMailer\\PHPMailer\\PHPMailer\:\:addReplyTo\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/registers.php - - - - message: '#^Parameter \#1 \$address of method PHPMailer\\PHPMailer\\PHPMailer\:\:setFrom\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/registers.php - - message: '#^Parameter \#1 \$client of class Appwrite\\PubSub\\Adapter\\Redis constructor expects Redis, Redis\|Swoole\\Database\\PDOProxy\|Utopia\\Mongo\\Client given\.$#' identifier: argument.type @@ -5169,7 +5097,7 @@ parameters: - message: '#^Parameter \#1 \$database of class Utopia\\Mongo\\Client constructor expects string, string\|null given\.$#' identifier: argument.type - count: 2 + count: 1 path: app/init/registers.php - @@ -5196,30 +5124,12 @@ parameters: count: 1 path: app/init/registers.php - - - message: '#^Parameter \#1 \$string of function urldecode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/registers.php - - - - message: '#^Parameter \#1 \$value of static method Utopia\\Http\\Http\:\:setMode\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/registers.php - - message: '#^Parameter \#2 \$default of static method Utopia\\System\\System\:\:getEnv\(\) expects string\|null, int given\.$#' identifier: argument.type count: 7 path: app/init/registers.php - - - message: '#^Parameter \#2 \$host of class Utopia\\Mongo\\Client constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/registers.php - - message: '#^Parameter \#2 \$key of class Utopia\\Logger\\Adapter\\Sentry constructor expects string, string\|null given\.$#' identifier: argument.type @@ -5241,18 +5151,12 @@ parameters: - message: '#^Parameter \#4 \$user of class Utopia\\Mongo\\Client constructor expects string, string\|null given\.$#' identifier: argument.type - count: 2 + count: 1 path: app/init/registers.php - message: '#^Parameter \#5 \$password of class Utopia\\Mongo\\Client constructor expects string, string\|null given\.$#' identifier: argument.type - count: 2 - path: app/init/registers.php - - - - message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$Host \(string\) does not accept string\|null\.$#' - identifier: assign.propertyType count: 1 path: app/init/registers.php @@ -5263,13 +5167,7 @@ parameters: path: app/init/registers.php - - message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$Port \(int\) does not accept string\|null\.$#' - identifier: assign.propertyType - count: 1 - path: app/init/registers.php - - - - message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$SMTPSecure \(string\) does not accept string\|null\.$#' + message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$Port \(int\) does not accept string\.$#' identifier: assign.propertyType count: 1 path: app/init/registers.php @@ -5493,7 +5391,7 @@ parameters: - message: '#^Expression on left side of \?\? is not nullable\.$#' identifier: nullCoalesce.expr - count: 1 + count: 2 path: app/init/resources.php - @@ -5544,12 +5442,6 @@ parameters: count: 1 path: app/init/resources.php - - - message: '#^Parameter \#1 \$default of class Utopia\\Locale\\Locale constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - message: '#^Parameter \#1 \$dsn of class Utopia\\DSN\\DSN constructor expects string, mixed given\.$#' identifier: argument.type @@ -5562,12 +5454,6 @@ parameters: count: 8 path: app/init/resources.php - - - message: '#^Parameter \#1 \$host of method Redis\:\:pconnect\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - message: '#^Parameter \#1 \$input of class Utopia\\Database\\Document constructor expects array\, mixed given\.$#' identifier: argument.type @@ -5586,18 +5472,6 @@ parameters: count: 1 path: app/init/resources.php - - - message: '#^Parameter \#1 \$name of class Utopia\\Queue\\Queue constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#1 \$name of method Utopia\\Locale\\Locale\:\:setFallback\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - message: '#^Parameter \#1 \$name of method Utopia\\Pools\\Group\:\:get\(\) expects string, mixed given\.$#' identifier: argument.type @@ -5658,42 +5532,6 @@ parameters: count: 1 path: app/init/resources.php - - - message: '#^Parameter \#2 \$accessKey of class Utopia\\Storage\\Device\\AWS constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#2 \$accessKey of class Utopia\\Storage\\Device\\Backblaze constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#2 \$accessKey of class Utopia\\Storage\\Device\\DOSpaces constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#2 \$accessKey of class Utopia\\Storage\\Device\\Linode constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#2 \$accessKey of class Utopia\\Storage\\Device\\S3 constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#2 \$accessKey of class Utopia\\Storage\\Device\\Wasabi constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - message: '#^Parameter \#2 \$allowedSchemes of class Appwrite\\Network\\Validator\\Origin constructor expects array\, array given\.$#' identifier: argument.type @@ -5778,12 +5616,6 @@ parameters: count: 2 path: app/init/resources.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 3 - path: app/init/resources.php - - message: '#^Parameter \#2 \$value of method Appwrite\\Usage\\Context\:\:addMetric\(\) expects int, mixed given\.$#' identifier: argument.type @@ -5796,114 +5628,12 @@ parameters: count: 1 path: app/init/resources.php - - - message: '#^Parameter \#3 \$secretKey of class Utopia\\Storage\\Device\\AWS constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#3 \$secretKey of class Utopia\\Storage\\Device\\Backblaze constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#3 \$secretKey of class Utopia\\Storage\\Device\\DOSpaces constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#3 \$secretKey of class Utopia\\Storage\\Device\\Linode constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#3 \$secretKey of class Utopia\\Storage\\Device\\S3 constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#3 \$secretKey of class Utopia\\Storage\\Device\\Wasabi constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#4 \$bucket of class Utopia\\Storage\\Device\\AWS constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#4 \$bucket of class Utopia\\Storage\\Device\\Backblaze constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#4 \$bucket of class Utopia\\Storage\\Device\\DOSpaces constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#4 \$bucket of class Utopia\\Storage\\Device\\Linode constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#4 \$bucket of class Utopia\\Storage\\Device\\Wasabi constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - message: '#^Parameter \#5 \$queueForFunctions of closure expects Appwrite\\Event\\Func, Appwrite\\Event\\Event given\.$#' identifier: argument.type count: 1 path: app/init/resources.php - - - message: '#^Parameter \#5 \$region of class Utopia\\Storage\\Device\\AWS constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#5 \$region of class Utopia\\Storage\\Device\\Backblaze constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#5 \$region of class Utopia\\Storage\\Device\\DOSpaces constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#5 \$region of class Utopia\\Storage\\Device\\Linode constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#5 \$region of class Utopia\\Storage\\Device\\S3 constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - - - message: '#^Parameter \#5 \$region of class Utopia\\Storage\\Device\\Wasabi constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/init/resources.php - - message: '#^Parameter \#6 \$queueForWebhooks of closure expects Appwrite\\Event\\Webhook, Appwrite\\Event\\Event given\.$#' identifier: argument.type @@ -6234,12 +5964,6 @@ parameters: count: 1 path: app/realtime.php - - - message: '#^Parameter \#1 \$host of method Redis\:\:pconnect\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/realtime.php - - message: '#^Parameter \#1 \$json of function json_decode expects string, mixed given\.$#' identifier: argument.type @@ -6348,12 +6072,6 @@ parameters: count: 4 path: app/realtime.php - - - message: '#^Parameter \#1 \$version of method Utopia\\Logger\\Log\:\:setVersion\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/realtime.php - - message: '#^Parameter \#2 \$array of function array_key_exists expects array, mixed given\.$#' identifier: argument.type @@ -6396,12 +6114,6 @@ parameters: count: 5 path: app/realtime.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/realtime.php - - message: '#^Parameter \#2 \$value of method Utopia\\Abuse\\Adapter\:\:setParam\(\) expects string, int given\.$#' identifier: argument.type @@ -6433,7 +6145,7 @@ parameters: path: app/realtime.php - - message: '#^Parameter \$port of class Utopia\\WebSocket\\Adapter\\Swoole constructor expects int, string\|null given\.$#' + message: '#^Parameter \$port of class Utopia\\WebSocket\\Adapter\\Swoole constructor expects int, string given\.$#' identifier: argument.type count: 1 path: app/realtime.php @@ -6463,7 +6175,7 @@ parameters: path: app/worker.php - - message: '#^Binary operation "\*" between \-1 and string\|null results in an error\.$#' + message: '#^Binary operation "\*" between \-1 and string results in an error\.$#' identifier: binaryOp.invalid count: 3 path: app/worker.php @@ -6552,24 +6264,12 @@ parameters: count: 1 path: app/worker.php - - - message: '#^Parameter \#1 \$host of method Redis\:\:pconnect\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/worker.php - - message: '#^Parameter \#1 \$input of class Utopia\\Database\\Document constructor expects array\, mixed given\.$#' identifier: argument.type count: 1 path: app/worker.php - - - message: '#^Parameter \#1 \$name of class Utopia\\Queue\\Queue constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: app/worker.php - - message: '#^Parameter \#1 \$pool of class Utopia\\Cache\\Adapter\\Pool constructor expects Utopia\\Pools\\Pool\, mixed given\.$#' identifier: argument.type @@ -6594,12 +6294,6 @@ parameters: count: 2 path: app/worker.php - - - message: '#^Parameter \#1 \$version of method Utopia\\Logger\\Log\:\:setVersion\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: app/worker.php - - message: '#^Parameter \#2 \$cache of class Utopia\\Database\\Database constructor expects Utopia\\Cache\\Cache, mixed given\.$#' identifier: argument.type @@ -6624,12 +6318,6 @@ parameters: count: 3 path: app/worker.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 3 - path: app/worker.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -10908,66 +10596,24 @@ parameters: count: 1 path: src/Appwrite/Event/Audit.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Audit.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Audit.php - - message: '#^Method Appwrite\\Event\\Build\:\:preparePayload\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Appwrite/Event/Build.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Build.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Build.php - - message: '#^Method Appwrite\\Event\\Certificate\:\:preparePayload\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Appwrite/Event/Certificate.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Certificate.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Certificate.php - - message: '#^Method Appwrite\\Event\\Database\:\:preparePayload\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Appwrite/Event/Database.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Database.php - - message: '#^Parameter \#1 \$dsn of class Utopia\\DSN\\DSN constructor expects string, mixed given\.$#' identifier: argument.type @@ -10992,18 +10638,6 @@ parameters: count: 1 path: src/Appwrite/Event/Delete.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Delete.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Delete.php - - message: '#^Method Appwrite\\Event\\Event\:\:generateEvents\(\) has parameter \$params with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -11202,18 +10836,6 @@ parameters: count: 1 path: src/Appwrite/Event/Func.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Func.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Func.php - - message: '#^Property Appwrite\\Event\\Func\:\:\$headers type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -11340,18 +10962,6 @@ parameters: count: 1 path: src/Appwrite/Event/Mail.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Mail.php - - message: '#^Property Appwrite\\Event\\Mail\:\:\$attachment type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -11484,18 +11094,6 @@ parameters: count: 1 path: src/Appwrite/Event/Messaging.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Messaging.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Messaging.php - - message: '#^Property Appwrite\\Event\\Messaging\:\:\$recipients type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -11508,18 +11106,6 @@ parameters: count: 1 path: src/Appwrite/Event/Migration.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Migration.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Migration.php - - message: '#^Cannot call method getId\(\) on Utopia\\Database\\Document\|null\.$#' identifier: method.nonObject @@ -11586,36 +11172,12 @@ parameters: count: 1 path: src/Appwrite/Event/Screenshot.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Screenshot.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Screenshot.php - - message: '#^Method Appwrite\\Event\\StatsResources\:\:preparePayload\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Appwrite/Event/StatsResources.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/StatsResources.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/StatsResources.php - - message: '#^Cannot access offset ''\$resource'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -11676,18 +11238,6 @@ parameters: count: 1 path: src/Appwrite/Event/Webhook.php - - - message: '#^Parameter \#1 \$class of method Appwrite\\Event\\Event\:\:setClass\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Webhook.php - - - - message: '#^Parameter \#1 \$queue of method Appwrite\\Event\\Event\:\:setQueue\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Event/Webhook.php - - message: '#^Cannot access offset ''code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -15054,6 +14604,438 @@ parameters: count: 1 path: src/Appwrite/Platform/Action.php + - + message: '#^Cannot access offset ''appDomain'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''appDomain''\|''database''\|''emailCertificates''\|''httpPort''\|''httpsPort'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''assistantOpenAIKey''\|''opensslKey'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''assistantOpenAIKeyHash''\|''opensslKeyHash'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''database'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''emailCertificates'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''httpPort'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot access offset ''httpsPort'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 3 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Http\\Installer\\Install\:\:action\(\) has parameter \$paths with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Http\\Installer\\Install\:\:buildErrorDetails\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Http\\Installer\\Install\:\:writeSseEvent\(\) has parameter \$payload with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Offset ''payload'' might not exist on array\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Parameter \#1 \$httpPort of method Appwrite\\Platform\\Tasks\\Install\:\:performInstallation\(\) expects string, int\\|int\<1, max\>\|string given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Parameter \#1 \$string of function ucfirst expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Parameter \#2 \$httpsPort of method Appwrite\\Platform\\Tasks\\Install\:\:performInstallation\(\) expects string, int\\|int\<1, max\>\|string given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Strict comparison using \!\=\= between non\-empty\-string and '''' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 3 + path: src/Appwrite/Platform/Installer/Http/Installer/Install.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/Status.php + + - + message: '#^Binary operation "\." between mixed and ''/installer\.phtml'' results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/View.php + + - + message: '#^Binary operation "\." between mixed and ''/installer…'' results in an error\.$#' + identifier: binaryOp.invalid + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/View.php + + - + message: '#^Cannot access offset ''default'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/View.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Http\\Installer\\View\:\:action\(\) has parameter \$paths with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Http/Installer/View.php + + - + message: '#^Parameter \#1 \$data of method Utopia\\Http\\Response\:\:html\(\) expects string, string\|false given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Http/Installer/View.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 6 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:__construct\(\) has parameter \$values with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:apply\(\) has parameter \$values with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:containsKnownKeys\(\) has parameter \$values with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:getVars\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:hasValidStringValue\(\) has parameter \$values with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:setVars\(\) has parameter \$vars with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Property Appwrite\\Platform\\Installer\\Runtime\\Config\:\:\$vars type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/Config.php + + - + message: '#^Cannot access offset ''installId'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Cannot access offset mixed on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:__construct\(\) has parameter \$paths with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:applyEnvConfig\(\) has parameter \$cfg with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:buildConfig\(\) has parameter \$overrides with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:isGlobalLockActive\(\) has parameter \$lock with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:isValidPort\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:parseEnvFile\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:readConfigFile\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:readProgressFile\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:sanitizeInstallId\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:withGlobalLock\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Runtime\\State\:\:writeProgressFile\(\) has parameter \$payload with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Offset 1 on array\{0\: non\-falsy\-string, 1\: non\-empty\-string, 2\?\: numeric\-string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Parameter \#1 \$lock of method Appwrite\\Platform\\Installer\\Runtime\\State\:\:isGlobalLockActive\(\) expects array\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Parameter \#1 \$stream of function ftruncate expects resource, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Parameter \#1 \$stream of function fwrite expects resource, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Parameter \#1 \$stream of function rewind expects resource, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Parameter \#1 \$string of function trim expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Parameter \#2 \$data of function fwrite expects string, string\|false given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Possibly invalid array key type mixed\.$#' + identifier: offsetAccess.invalidOffset + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Property Appwrite\\Platform\\Installer\\Runtime\\State\:\:\$paths is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Property Appwrite\\Platform\\Installer\\Runtime\\State\:\:\$paths type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Runtime/State.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Cannot cast non\-empty\-list\\|non\-falsy\-string to string\.$#' + identifier: cast.string + count: 2 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Method Appwrite\\Platform\\Installer\\Server\:\:startDockerInstaller\(\) has parameter \$opts with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#1 \$directory of method Utopia\\Http\\Files\:\:load\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#1 \$filename of function unlink expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#1 \$path of function realpath expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#1 \$value of method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:setLockedDatabase\(\) expects string\|null, list\\|string given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#1 \$value of method Appwrite\\Platform\\Installer\\Runtime\\Config\:\:setLockedDatabase\(\) expects string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#2 \$port of class Utopia\\Http\\Adapter\\Swoole\\Server@anonymous/src/Appwrite/Platform/Installer/Server\.php\:156 constructor expects string\|null, int given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#2 \$port of method Appwrite\\Platform\\Installer\\Server\:\:printInstallerUrl\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Parameter \#3 \$readyFile of method Appwrite\\Platform\\Installer\\Server\:\:startSwooleServer\(\) expects string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Property Appwrite\\Platform\\Installer\\Server\:\:\$paths type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Installer/Server.php + + - + message: '#^Offset 1 on array\{0\: non\-falsy\-string, 1\: non\-empty\-string, 2\?\: numeric\-string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Appwrite/Platform/Installer/Validator/AppDomain.php + - message: '#^Parameter \#1 \$issuer of method Appwrite\\Auth\\MFA\\Type\:\:setIssuer\(\) expects string, mixed given\.$#' identifier: argument.type @@ -16225,13 +16207,13 @@ parameters: path: src/Appwrite/Platform/Modules/Compute/Base.php - - message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string\|null given\.$#' + message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string given\.$#' identifier: argument.type count: 1 path: src/Appwrite/Platform/Modules/Compute/Base.php - - message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string\|null given\.$#' + message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string given\.$#' identifier: argument.type count: 1 path: src/Appwrite/Platform/Modules/Compute/Base.php @@ -16350,12 +16332,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 3 - path: src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php - - message: '#^Method Appwrite\\Platform\\Modules\\Console\\Http\\Variables\\Get\:\:action\(\) has no return type specified\.$#' identifier: missingType.return @@ -16374,12 +16350,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php - - message: '#^Unary operation "\+" on string\|null results in an error\.$#' identifier: unaryOp.invalid @@ -21432,12 +21402,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - - message: '#^Parameter \#2 \$default of method Utopia\\Http\\Request\:\:getHeader\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - message: '#^Parameter \#2 \$default of static method Utopia\\System\\System\:\:getEnv\(\) expects string\|null, int given\.$#' identifier: argument.type @@ -21456,12 +21420,6 @@ parameters: count: 2 path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - message: '#^Parameter \#2 \$value of method Utopia\\Http\\Response\:\:addHeader\(\) expects string, \(float\|int\) given\.$#' identifier: argument.type @@ -21475,13 +21433,13 @@ parameters: path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string\|null given\.$#' + message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string\|null given\.$#' + message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -21631,13 +21589,13 @@ parameters: path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php - - message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string\|null given\.$#' + message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php - - message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string\|null given\.$#' + message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -21708,12 +21666,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Functions/Http/Runtimes/XList.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Runtimes/XList.php - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable @@ -22860,12 +22812,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php - - - message: '#^Parameter \#1 \$host of class Appwrite\\ClamAV\\Network constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Health/Http/Health/AntiVirus/Get.php - - message: '#^Parameter \#2 \$default of static method Utopia\\System\\System\:\:getEnv\(\) expects string\|null, int given\.$#' identifier: argument.type @@ -23304,12 +23250,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 5 - path: src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php - - message: '#^Possibly invalid array key type mixed\.$#' identifier: offsetAccess.invalidOffset @@ -23598,12 +23538,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Proxy/Action.php - - - message: '#^Parameter \#1 \$domain of class Utopia\\Domains\\Domain constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Proxy/Action.php - - message: '#^Parameter \#1 \$validators of class Utopia\\Validator\\AnyOf constructor expects array\, list\ given\.$#' identifier: argument.type @@ -23616,12 +23550,6 @@ parameters: count: 2 path: src/Appwrite/Platform/Modules/Proxy/Action.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 8 - path: src/Appwrite/Platform/Modules/Proxy/Action.php - - message: '#^Method Appwrite\\Platform\\Modules\\Proxy\\Http\\Rules\\API\\Create\:\:__construct\(\) has parameter \$params with no type specified\.$#' identifier: missingType.parameter @@ -24415,13 +24343,13 @@ parameters: path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php - - message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string\|null given\.$#' + message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php - - message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string\|null given\.$#' + message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -24535,13 +24463,13 @@ parameters: path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php - - message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string\|null given\.$#' + message: '#^Parameter \#3 \$maxCpus of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects float, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php - - message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string\|null given\.$#' + message: '#^Parameter \#4 \$maxMemory of class Appwrite\\Platform\\Modules\\Compute\\Validator\\Specification constructor expects int, string given\.$#' identifier: argument.type count: 2 path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -25165,7 +25093,7 @@ parameters: path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php - - message: '#^Parameter \#1 \$bytes of static method Utopia\\Storage\\Storage\:\:human\(\) expects int, string\|null given\.$#' + message: '#^Parameter \#1 \$bytes of static method Utopia\\Storage\\Storage\:\:human\(\) expects int, string given\.$#' identifier: argument.type count: 1 path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php @@ -25230,12 +25158,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php - - - message: '#^Parameter \#1 \$host of class Appwrite\\ClamAV\\Network constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php - - message: '#^Parameter \#1 \$key of method Utopia\\Http\\Adapter\\Swoole\\Request\:\:getFiles\(\) expects string, int given\.$#' identifier: argument.type @@ -26011,7 +25933,7 @@ parameters: path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php - - message: '#^Parameter \#1 \$bytes of static method Utopia\\Storage\\Storage\:\:human\(\) expects int, string\|null given\.$#' + message: '#^Parameter \#1 \$bytes of static method Utopia\\Storage\\Storage\:\:human\(\) expects int, string given\.$#' identifier: argument.type count: 1 path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php @@ -27342,12 +27264,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php - - - message: '#^Method Appwrite\\Platform\\Modules\\VCS\\Http\\GitHub\\Authorize\\External\\Update\:\:getBuildQueueName\(\) should return string but returns string\|null\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php - - message: '#^Method Appwrite\\Platform\\Modules\\VCS\\Http\\GitHub\\Authorize\\External\\Update\:\:getName\(\) has no return type specified\.$#' identifier: missingType.return @@ -27708,12 +27624,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - - message: '#^Parameter \#1 \$appId of class Appwrite\\Auth\\OAuth2\\Github constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - message: '#^Parameter \#1 \$haystack of function str_contains expects string, mixed given\.$#' identifier: argument.type @@ -27732,12 +27642,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - - message: '#^Parameter \#2 \$appSecret of class Appwrite\\Auth\\OAuth2\\Github constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - message: '#^Parameter \#2 \$id of method Utopia\\Database\\Database\:\:getDocument\(\) expects string, mixed given\.$#' identifier: argument.type @@ -27906,12 +27810,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - message: '#^Method Appwrite\\Platform\\Modules\\VCS\\Http\\GitHub\\Events\\Create\:\:getBuildQueueName\(\) should return string but returns string\|null\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - message: '#^Method Appwrite\\Platform\\Modules\\VCS\\Http\\GitHub\\Events\\Create\:\:getName\(\) has no return type specified\.$#' identifier: missingType.return @@ -28170,12 +28068,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - message: '#^Parameter \#3 \$signatureKey of method Utopia\\VCS\\Adapter\\Git\\GitHub\:\:validateWebhookEvent\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - message: '#^Parameter \#4 \$providerBranch of method Appwrite\\Platform\\Modules\\VCS\\Http\\GitHub\\Events\\Create\:\:createGitDeployments\(\) expects string, mixed given\.$#' identifier: argument.type @@ -28470,12 +28362,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php - - - message: '#^Parameter \#1 \$appId of class Appwrite\\Auth\\OAuth2\\Github constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php - - message: '#^Parameter \#1 \$datetime of class DateTime constructor expects string, mixed given\.$#' identifier: argument.type @@ -28512,12 +28398,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php - - - message: '#^Parameter \#2 \$appSecret of class Appwrite\\Auth\\OAuth2\\Github constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php - - message: '#^Parameter \#2 \$privateKey of method Utopia\\VCS\\Adapter\\Git\\GitHub\:\:initializeVariables\(\) expects string, string\|null given\.$#' identifier: argument.type @@ -28614,12 +28494,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php - - - message: '#^Call to an undefined method Utopia\\VCS\\Adapter\\Git\\GitHub\:\:getInstallationRepository\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php - - message: '#^Expression on left side of \?\? is not nullable\.$#' identifier: nullCoalesce.expr @@ -29028,12 +28902,6 @@ parameters: count: 2 path: src/Appwrite/Platform/Tasks/Doctor.php - - - message: '#^Parameter \#1 \$host of class Appwrite\\ClamAV\\Network constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/Doctor.php - - message: '#^Parameter \#1 \$json of function json_decode expects string, string\|false given\.$#' identifier: argument.type @@ -29070,18 +28938,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/Doctor.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/Doctor.php - - - - message: '#^Parameter \#2 \$version2 of function version_compare expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/Doctor.php - - message: '#^Part \$database \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString @@ -29094,12 +28950,24 @@ parameters: count: 3 path: src/Appwrite/Platform/Tasks/Doctor.php + - + message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Tasks/Doctor.php + - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable count: 2 path: src/Appwrite/Platform/Tasks/Install.php + - + message: '#^Binary operation "\." between ''https\://'' and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + - message: '#^Binary operation "\." between mixed and '' \(default\: \\'''' results in an error\.$#' identifier: binaryOp.invalid @@ -29109,13 +28977,31 @@ parameters: - message: '#^Binary operation "\." between non\-falsy\-string and mixed results in an error\.$#' identifier: binaryOp.invalid + count: 2 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Cannot access offset ''\$id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Appwrite/Platform/Tasks/Install.php - message: '#^Cannot access offset ''default'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 8 + count: 6 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Cannot access offset ''done''\|''start'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Cannot access offset ''expire'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 path: src/Appwrite/Platform/Tasks/Install.php - @@ -29125,11 +29011,23 @@ parameters: path: src/Appwrite/Platform/Tasks/Install.php - - message: '#^Cannot access offset ''name'' on mixed\.$#' + message: '#^Cannot access offset ''id'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible count: 2 path: src/Appwrite/Platform/Tasks/Install.php + - + message: '#^Cannot access offset ''message'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 10 + path: src/Appwrite/Platform/Tasks/Install.php + - message: '#^Cannot access offset ''overwrite'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -29148,12 +29046,138 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/Install.php + - + message: '#^Cannot access offset ''secret'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + - message: '#^Cannot access offset ''variables'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Appwrite/Platform/Tasks/Install.php + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 3 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Comparison operation "\<\=" between 0\|1\|2 and 2 is always true\.$#' + identifier: smallerOrEqual.alwaysTrue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:buildStepMessages\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:createInitialAdminAccount\(\) has parameter \$account with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:makeApiCall\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:makeApiCall\(\) has parameter \$body with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:performInstallation\(\) has parameter \$account with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:performInstallation\(\) has parameter \$input with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:prepareEnvironmentVariables\(\) has parameter \$userInput with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:prepareEnvironmentVariables\(\) has parameter \$vars with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:prepareEnvironmentVariables\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:readInstallerConfig\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:readInstallerConfig\(\) should return array but returns array\|null\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:runDockerCompose\(\) has parameter \$input with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:setInstallerConfig\(\) has parameter \$config with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:startWebServer\(\) has parameter \$vars with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:trackSelfHostedInstall\(\) has parameter \$account with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:trackSelfHostedInstall\(\) has parameter \$input with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:updateProgress\(\) has parameter \$details with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Install\:\:updateProgress\(\) has parameter \$messages with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + - message: '#^Negated boolean expression is always false\.$#' identifier: booleanNot.alwaysFalse @@ -29161,7 +29185,31 @@ parameters: path: src/Appwrite/Platform/Tasks/Install.php - - message: '#^Parameter \#1 \$arg of function escapeshellarg expects string, mixed given\.$#' + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Parameter \#1 \$domain of method Appwrite\\Platform\\Tasks\\Install\:\:waitForApiReady\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Parameter \#1 \$haystack of function str_starts_with expects string, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Parameter \#1 \$varName of method Appwrite\\Platform\\Tasks\\Install\:\:generatePasswordValue\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Parameter \#4 \$domain of method Appwrite\\Platform\\Tasks\\Install\:\:createInitialAdminAccount\(\) expects string, mixed given\.$#' identifier: argument.type count: 1 path: src/Appwrite/Platform/Tasks/Install.php @@ -29173,7 +29221,7 @@ parameters: path: src/Appwrite/Platform/Tasks/Install.php - - message: '#^Part \$input\[\$var\[''name''\]\] \(mixed\) of encapsed string cannot be cast to string\.$#' + message: '#^Part \$message \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString count: 1 path: src/Appwrite/Platform/Tasks/Install.php @@ -29181,7 +29229,13 @@ parameters: - message: '#^Possibly invalid array key type mixed\.$#' identifier: offsetAccess.invalidOffset - count: 10 + count: 9 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Property Appwrite\\Platform\\Tasks\\Install\:\:\$installerConfig type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 path: src/Appwrite/Platform/Tasks/Install.php - @@ -29190,12 +29244,24 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/Install.php + - + message: '#^Strict comparison using \!\=\= between string and false will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + - message: '#^Using nullsafe method call on non\-nullable type Appwrite\\Docker\\Compose\\Service\. Use \-\> instead\.$#' identifier: nullsafe.neverNull count: 1 path: src/Appwrite/Platform/Tasks/Install.php + - + message: '#^Variable \$compose in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable @@ -29274,12 +29340,6 @@ parameters: count: 2 path: src/Appwrite/Platform/Tasks/Interval.php - - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' - identifier: argument.type - count: 2 - path: src/Appwrite/Platform/Tasks/Interval.php - - message: '#^Part \$taskName \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString @@ -29322,12 +29382,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/Maintenance.php - - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/Maintenance.php - - message: '#^Parameter \#1 \$pdo of method Appwrite\\Migration\\Migration\:\:setPDO\(\) expects Utopia\\Database\\PDO, mixed given\.$#' identifier: argument.type @@ -29976,12 +30030,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/SDKs.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/SSL.php - - message: '#^Binary operation "\*" between mixed and 1000 results in an error\.$#' identifier: binaryOp.invalid @@ -30054,12 +30102,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/ScheduleBase.php - - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/ScheduleBase.php - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, list given\.$#' identifier: argument.type @@ -30811,10 +30853,22 @@ parameters: path: src/Appwrite/Platform/Tasks/StatsResources.php - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' - identifier: argument.type + message: '#^Call to static method error\(\) on an unknown class Utopia\\CLI\\Console\.$#' + identifier: class.notFound count: 1 - path: src/Appwrite/Platform/Tasks/StatsResources.php + path: src/Appwrite/Platform/Tasks/Upgrade.php + + - + message: '#^Call to static method log\(\) on an unknown class Utopia\\CLI\\Console\.$#' + identifier: class.notFound + count: 5 + path: src/Appwrite/Platform/Tasks/Upgrade.php + + - + message: '#^Method Appwrite\\Platform\\Tasks\\Upgrade\:\:startWebServer\(\) has parameter \$vars with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Appwrite/Platform/Tasks/Upgrade.php - message: '#^Negated boolean expression is always false\.$#' @@ -30823,14 +30877,14 @@ parameters: path: src/Appwrite/Platform/Tasks/Upgrade.php - - message: '#^Parameter \#1 \$data of class Appwrite\\Docker\\Compose constructor expects string, string\|false given\.$#' + message: '#^Parameter \#7 \$database of method Appwrite\\Platform\\Tasks\\Install\:\:action\(\) expects string, mixed given\.$#' identifier: argument.type count: 1 path: src/Appwrite/Platform/Tasks/Upgrade.php - - message: '#^Parameter \#7 \$database of method Appwrite\\Platform\\Tasks\\Install\:\:action\(\) expects string, mixed given\.$#' - identifier: argument.type + message: '#^Property Appwrite\\Platform\\Tasks\\Upgrade\:\:\$lockedDatabase \(string\|null\) does not accept mixed\.$#' + identifier: assign.propertyType count: 1 path: src/Appwrite/Platform/Tasks/Upgrade.php @@ -30864,12 +30918,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Tasks/Vars.php - - - message: '#^Parameter \#1 \$message of static method Utopia\\Console\:\:log\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Tasks/Version.php - - message: '#^Cannot access an offset on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -30978,12 +31026,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Certificates.php - - - message: '#^Parameter \#1 \$default of class Utopia\\Locale\\Locale constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Workers/Certificates.php - - message: '#^Parameter \#1 \$dnsValidatorClass of method Appwrite\\Platform\\Modules\\Proxy\\Action\:\:__construct\(\) expects string, mixed given\.$#' identifier: argument.type @@ -31002,12 +31044,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Certificates.php - - - message: '#^Parameter \#1 \$name of method Utopia\\Locale\\Locale\:\:setFallback\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Workers/Certificates.php - - message: '#^Parameter \#1 \$preview of method Appwrite\\Event\\Mail\:\:setPreview\(\) expects string, mixed given\.$#' identifier: argument.type @@ -31368,12 +31404,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Deletes.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Appwrite/Platform/Workers/Deletes.php - - message: '#^Parameter \#2 \$value of method Utopia\\Logger\\Log\:\:addTag\(\) expects string, mixed given\.$#' identifier: argument.type @@ -31401,7 +31431,7 @@ parameters: - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' identifier: argument.type - count: 40 + count: 38 path: src/Appwrite/Platform/Workers/Deletes.php - @@ -31512,12 +31542,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Functions.php - - - message: '#^Binary operation "\." between ''cd /usr/local…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Appwrite/Platform/Workers/Functions.php - - message: '#^Cannot access offset ''cpus'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -31698,6 +31722,12 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Functions.php + - + message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Appwrite/Platform/Workers/Functions.php + - message: '#^Parameter \$cpus of method Executor\\Executor\:\:createExecution\(\) expects float, mixed given\.$#' identifier: argument.type @@ -31998,12 +32028,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Mails.php - - - message: '#^Parameter \#1 \$string of function urldecode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Workers/Mails.php - - message: '#^Parameter \#2 \$filename of method PHPMailer\\PHPMailer\\PHPMailer\:\:addStringAttachment\(\) expects string, mixed given\.$#' identifier: argument.type @@ -32850,12 +32874,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Messaging.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Workers/Messaging.php - - message: '#^Parameter \#2 \$subject of class Utopia\\Messaging\\Messages\\Email constructor expects string, mixed given\.$#' identifier: argument.type @@ -33450,12 +33468,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Migrations.php - - - message: '#^Parameter \#1 \$default of class Utopia\\Locale\\Locale constructor expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Workers/Migrations.php - - message: '#^Parameter \#1 \$deviceForFiles of class Utopia\\Migration\\Destinations\\CSV constructor expects Utopia\\Storage\\Device, Utopia\\Storage\\Device\|null given\.$#' identifier: argument.type @@ -33510,12 +33522,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Workers/Migrations.php - - - message: '#^Parameter \#1 \$name of method Utopia\\Locale\\Locale\:\:setFallback\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Platform/Workers/Migrations.php - - message: '#^Parameter \#1 \$preview of method Appwrite\\Event\\Mail\:\:setPreview\(\) expects string, mixed given\.$#' identifier: argument.type @@ -36612,12 +36618,6 @@ parameters: count: 1 path: src/Appwrite/Utopia/Request.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Appwrite/Utopia/Request.php - - message: '#^Part \$key \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString @@ -38814,12 +38814,6 @@ parameters: count: 1 path: src/Executor/Executor.php - - - message: '#^Property Executor\\Executor\:\:\$endpoint \(string\) does not accept string\|null\.$#' - identifier: assign.propertyType - count: 1 - path: src/Executor/Executor.php - - message: '#^Property Executor\\Executor\:\:\$headers type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -40668,12 +40662,6 @@ parameters: count: 2 path: tests/e2e/General/UsageTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 3 - path: tests/e2e/General/UsageTest.php - - message: '#^Property Tests\\E2E\\General\\UsageTest\:\:\$key type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -47343,7 +47331,7 @@ parameters: - message: '#^Cannot access offset ''status\-code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 27 + count: 30 path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsGuestTest.php - @@ -47385,7 +47373,7 @@ parameters: - message: '#^Cannot call method call\(\) on Tests\\E2E\\Client\|null\.$#' identifier: method.nonObject - count: 38 + count: 39 path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsGuestTest.php - @@ -47769,7 +47757,7 @@ parameters: - message: '#^Cannot access offset ''status\-code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 28 + count: 31 path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsMemberTest.php - @@ -47805,7 +47793,7 @@ parameters: - message: '#^Cannot call method call\(\) on Tests\\E2E\\Client\|null\.$#' identifier: method.nonObject - count: 31 + count: 32 path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsMemberTest.php - @@ -48183,7 +48171,7 @@ parameters: - message: '#^Cannot access offset ''status\-code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 21 + count: 24 path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php - @@ -48219,7 +48207,7 @@ parameters: - message: '#^Cannot call method call\(\) on Tests\\E2E\\Client\|null\.$#' identifier: method.nonObject - count: 27 + count: 28 path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php - @@ -50922,12 +50910,6 @@ parameters: count: 1 path: tests/e2e/Services/Functions/FunctionsConsoleClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Functions/FunctionsConsoleClientTest.php - - message: '#^Part \$this\-\>getProject\(\)\[''\$id''\] \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString @@ -51528,12 +51510,6 @@ parameters: count: 1 path: tests/e2e/Services/Functions/FunctionsCustomClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Functions/FunctionsCustomClientTest.php - - message: '#^Property Tests\\E2E\\Services\\Functions\\FunctionsCustomClientTest\:\:\$project type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -52572,12 +52548,6 @@ parameters: count: 1 path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php - - message: '#^Parameter \#2 \$string of function unpack expects string, mixed given\.$#' identifier: argument.type @@ -53052,12 +53022,6 @@ parameters: count: 2 path: tests/e2e/Services/FunctionsSchedule/FunctionsScheduleTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/FunctionsSchedule/FunctionsScheduleTest.php - - message: '#^Property Tests\\E2E\\Services\\FunctionsSchedule\\FunctionsScheduleTest\:\:\$key type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -55027,7 +54991,7 @@ parameters: path: tests/e2e/Services/GraphQL/HealthTest.php - - message: '#^Binary operation "\+" between string\|null and 1 results in an error\.$#' + message: '#^Binary operation "\+" between string and 1 results in an error\.$#' identifier: binaryOp.invalid count: 1 path: tests/e2e/Services/GraphQL/Legacy/AbuseTest.php @@ -57907,7 +57871,7 @@ parameters: path: tests/e2e/Services/GraphQL/StorageServerTest.php - - message: '#^Binary operation "\+" between string\|null and 1 results in an error\.$#' + message: '#^Binary operation "\+" between string and 1 results in an error\.$#' identifier: binaryOp.invalid count: 1 path: tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php @@ -64392,12 +64356,6 @@ parameters: count: 1 path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php - - message: '#^Parameter \#2 \$subject of function preg_match expects string, mixed given\.$#' identifier: argument.type @@ -65346,12 +65304,6 @@ parameters: count: 1 path: tests/e2e/Services/Projects/ProjectsCustomServerTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: tests/e2e/Services/Projects/ProjectsCustomServerTest.php - - message: '#^Property Tests\\E2E\\Services\\Projects\\ProjectsCustomServerTest\:\:\$key type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -65922,12 +65874,6 @@ parameters: count: 3 path: tests/e2e/Services/Proxy/ProxyCustomServerTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 3 - path: tests/e2e/Services/Proxy/ProxyCustomServerTest.php - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' identifier: argument.type @@ -66474,12 +66420,6 @@ parameters: count: 28 path: tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php - - message: '#^Part \$actorsId \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString @@ -67086,12 +67026,6 @@ parameters: count: 10 path: tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php - - message: '#^Parameter \#2 \$values of static method Utopia\\Database\\Query\:\:equal\(\) expects array\\|bool\|float\|int\|string\>, array\ given\.$#' identifier: argument.type @@ -67782,12 +67716,6 @@ parameters: count: 3 path: tests/e2e/Services/Realtime/RealtimeCustomClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Realtime/RealtimeCustomClientTest.php - - message: '#^Parameter \#3 \$message of method PHPUnit\\Framework\\Assert\:\:assertEquals\(\) expects string, string\|false given\.$#' identifier: argument.type @@ -68274,12 +68202,6 @@ parameters: count: 4 path: tests/e2e/Services/Sites/SitesConsoleClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Sites/SitesConsoleClientTest.php - - message: '#^Part \$screenshotId \(mixed\) of encapsed string cannot be cast to string\.$#' identifier: encapsedStringPart.nonString @@ -68634,12 +68556,6 @@ parameters: count: 2 path: tests/e2e/Services/Sites/SitesCustomClientTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: tests/e2e/Services/Sites/SitesCustomClientTest.php - - message: '#^Property Tests\\E2E\\Services\\Sites\\SitesCustomClientTest\:\:\$project type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -69450,12 +69366,6 @@ parameters: count: 1 path: tests/e2e/Services/Sites/SitesCustomServerTest.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: tests/e2e/Services/Sites/SitesCustomServerTest.php - - message: '#^Parameter \#2 \$string of method PHPUnit\\Framework\\Assert\:\:assertStringStartsWith\(\) expects string, mixed given\.$#' identifier: argument.type @@ -71289,7 +71199,7 @@ parameters: - message: '#^Cannot access offset ''status\-code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 33 + count: 36 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsGuestTest.php - @@ -71331,7 +71241,7 @@ parameters: - message: '#^Cannot call method call\(\) on Tests\\E2E\\Client\|null\.$#' identifier: method.nonObject - count: 38 + count: 39 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsGuestTest.php - @@ -71715,7 +71625,7 @@ parameters: - message: '#^Cannot access offset ''status\-code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 28 + count: 31 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsMemberTest.php - @@ -71751,7 +71661,7 @@ parameters: - message: '#^Cannot call method call\(\) on Tests\\E2E\\Client\|null\.$#' identifier: method.nonObject - count: 31 + count: 32 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsMemberTest.php - @@ -72129,7 +72039,7 @@ parameters: - message: '#^Cannot access offset ''status\-code'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 25 + count: 28 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php - @@ -72165,7 +72075,7 @@ parameters: - message: '#^Cannot call method call\(\) on Tests\\E2E\\Client\|null\.$#' identifier: method.nonObject - count: 27 + count: 28 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php - @@ -82014,18 +81924,6 @@ parameters: count: 7 path: tests/unit/Filter/BranchDomainTest.php - - - message: '#^Method Tests\\Unit\\Functions\\Validator\\HeadersBench\:\:benchHeadersValidator\(\) has parameter \$data with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/unit/Functions/Validator/HeadersBench.php - - - - message: '#^Method Tests\\Unit\\Functions\\Validator\\HeadersBench\:\:providers\(\) return type has no value type specified in iterable type iterable\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/unit/Functions/Validator/HeadersBench.php - - message: '#^Cannot call method isValid\(\) on Appwrite\\Functions\\Validator\\Headers\|null\.$#' identifier: method.nonObject @@ -82320,6 +82218,300 @@ parameters: count: 1 path: tests/unit/Platform/Modules/Compute/Validator/SpecificationTest.php + - + message: '#^Call to function method_exists\(\) with ''Appwrite\\\\Platform\\\\Installer\\\\Http\\\\Installer\\\\Validate'' and ''validateCsrf'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Cannot access offset ''type'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Cannot call method getActions\(\) on Utopia\\Platform\\Service\|false\.$#' + identifier: method.nonObject + count: 7 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Cannot call method getServicesByType\(\) on Appwrite\\Platform\\Installer\\Module\|null\.$#' + identifier: method.nonObject + count: 10 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Method Tests\\Unit\\Platform\\Modules\\Installer\\ModuleTest\:\:assertActionInjects\(\) has parameter \$expectedInjections with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Method Tests\\Unit\\Platform\\Modules\\Installer\\ModuleTest\:\:assertActionParams\(\) has parameter \$expectedParams with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/unit/Platform/Modules/Installer/ModuleTest.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php + + - + message: '#^Parameter \#2 \$haystack of method PHPUnit\\Framework\\Assert\:\:assertCount\(\) expects Countable\|iterable, mixed given\.$#' + identifier: argument.type + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 5 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''composeFile'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''database'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''defaultHttpPort'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''docker\-compose'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''env\-vars'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''httpPort'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''httpsPort'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''installId'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''message'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot access offset ''status'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method applyEnvConfig\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 3 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method buildConfig\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 12 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method hashSensitiveValue\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 12 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidAccountName\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 6 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidAppDomainInput\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 29 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidDatabaseAdapter\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 15 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidEmailAddress\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 11 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidPassword\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 12 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidPort\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 28 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method isValidSecretKey\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 8 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method progressFilePath\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 12 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method readProgressFile\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 19 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method reserveGlobalLock\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 14 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method sanitizeInstallId\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 15 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method updateGlobalLock\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 5 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Cannot call method writeProgressFile\(\) on Appwrite\\Platform\\Installer\\Runtime\\State\|null\.$#' + identifier: method.nonObject + count: 21 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Parameter \#1 \$filename of function unlink expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Parameter \#1 \$json of function json_decode expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Parameter \#1 \$prefix of method PHPUnit\\Framework\\Assert\:\:assertStringStartsWith\(\) expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Parameter \#2 \$array of method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) expects array\\|ArrayAccess\<\(int\|string\), mixed\>, mixed given\.$#' + identifier: argument.type + count: 6 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Parameter \#2 \$haystack of method PHPUnit\\Framework\\Assert\:\:assertCount\(\) expects Countable\|iterable, mixed given\.$#' + identifier: argument.type + count: 3 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Property Tests\\Unit\\Platform\\Modules\\Installer\\Runtime\\StateTest\:\:\$progressFiles type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/unit/Platform/Modules/Installer/Runtime/StateTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php + + - + message: '#^Cannot access constant TYPE_STRING on Appwrite\\Platform\\Installer\\Validator\\AppDomain\|null\.$#' + identifier: classConstant.nonObject + count: 1 + path: tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php + + - + message: '#^Cannot call method getDescription\(\) on Appwrite\\Platform\\Installer\\Validator\\AppDomain\|null\.$#' + identifier: method.nonObject + count: 2 + path: tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php + + - + message: '#^Cannot call method getType\(\) on Appwrite\\Platform\\Installer\\Validator\\AppDomain\|null\.$#' + identifier: method.nonObject + count: 1 + path: tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php + + - + message: '#^Cannot call method isArray\(\) on Appwrite\\Platform\\Installer\\Validator\\AppDomain\|null\.$#' + identifier: method.nonObject + count: 1 + path: tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php + + - + message: '#^Cannot call method isValid\(\) on Appwrite\\Platform\\Installer\\Validator\\AppDomain\|null\.$#' + identifier: method.nonObject + count: 61 + path: tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php + - message: '#^Cannot access offset ''host'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php new file mode 100644 index 0000000000..da8985396f --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php @@ -0,0 +1,72 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/complete') + ->desc('Complete installation') + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->param('sessionId', '', new Text(256, 0), 'Session ID', true) + ->param('sessionSecret', '', new Text(256, 0), 'Session secret', true) + ->param('sessionExpire', '', new Text(64, 0), 'Session expiry timestamp', true) + ->inject('request') + ->inject('response') + ->inject('installerState') + ->callback($this->action(...)); + } + + public function action(string $installId, string $sessionId, string $sessionSecret, string $sessionExpire, Request $request, Response $response, State $state): void + { + if (!Validate::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + + $installId = $state->sanitizeInstallId($installId); + + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } + + @touch(Server::INSTALLER_COMPLETE_FILE); + + if ($sessionSecret) { + $isHttps = $request->getProtocol() === 'https'; + $sameSite = $isHttps ? Response::COOKIE_SAMESITE_NONE : Response::COOKIE_SAMESITE_LAX; + $expires = 0; + if ($sessionExpire) { + $timestamp = strtotime($sessionExpire); + if ($timestamp !== false) { + $expires = $timestamp; + } + } + $response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); + $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); + if ($sessionId) { + $response->addHeader('X-Appwrite-Session', $sessionId); + } + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + + $response->json(['success' => true]); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Error.php b/src/Appwrite/Platform/Installer/Http/Installer/Error.php new file mode 100644 index 0000000000..506c545125 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Error.php @@ -0,0 +1,37 @@ +setType(Action::TYPE_ERROR) + ->inject('error') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(\Throwable $error, Response $response): void + { + if ($response->isSent()) { + return; + } + $code = $error->getCode(); + if ($code < 100 || $code > 599) { + $code = 500; + } + $response->setStatusCode($code); + $message = $code >= 500 ? 'Internal installer error' : $error->getMessage(); + $response->json(['success' => false, 'message' => $message]); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php new file mode 100644 index 0000000000..ee12dad444 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -0,0 +1,398 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install') + ->desc('Run installation') + ->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)') + ->param('httpPort', 80, new Range(1, 65535), 'HTTP port') + ->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port') + ->param('emailCertificates', '', new Email(), 'Email for SSL certificates') + ->param('opensslKey', '', new Text(64, 0), 'Secret API key', true) + ->param('assistantOpenAIKey', '', new Text(256, 0), 'OpenAI API key for assistant', true) + ->param('accountEmail', '', new Email(allowEmpty: true), 'Account email address', true) + ->param('accountPassword', '', new Password(allowEmpty: true), 'Account password', true) + ->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true) + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true) + ->inject('request') + ->inject('response') + ->inject('swooleResponse') + ->inject('installerState') + ->inject('installerConfig') + ->inject('installerPaths') + ->callback($this->action(...)); + } + + public function action( + string $appDomain, + int $httpPort, + int $httpsPort, + string $emailCertificates, + string $opensslKey, + string $assistantOpenAIKey, + string $accountEmail, + string $accountPassword, + string $database, + string $installId, + ?string $retryStep, + Request $request, + Response $response, + SwooleResponse $swooleResponse, + State $state, + Config $config, + array $paths + ): void { + $acceptHeader = $request->getHeader('accept'); + $wantsStream = stripos($acceptHeader, 'text/event-stream') !== false; + + if ($wantsStream) { + $swooleResponse->header('Content-Type', 'text/event-stream'); + $swooleResponse->header('Cache-Control', 'no-cache'); + $swooleResponse->header('Connection', 'keep-alive'); + $swooleResponse->header('X-Accel-Buffering', 'no'); + + $swooleResponse->write("event: ping\ndata: {\"time\":" . time() . "}\n\n"); + } + + if (!Validate::validateCsrf($request)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Invalid CSRF token'); + return; + } + + $appDomain = trim($appDomain); + $emailCertificates = trim($emailCertificates); + $opensslKey = trim($opensslKey); + $assistantOpenAIKey = trim($assistantOpenAIKey); + + if ($opensslKey === '' && !$config->isUpgrade()) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Secret key is required'); + return; + } + + $account = []; + if (!$config->isUpgrade()) { + $accountEmail = trim($accountEmail); + if ($accountEmail === '' || !$state->isValidEmailAddress($accountEmail)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Please enter a valid email address', Server::STEP_ACCOUNT_SETUP); + return; + } + + if (!$state->isValidPassword($accountPassword)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Password must be at least 8 characters', Server::STEP_ACCOUNT_SETUP); + return; + } + + $accountName = $this->deriveNameFromEmail($accountEmail); + + $account = [ + 'name' => $accountName, + 'email' => $accountEmail, + 'password' => $accountPassword, + ]; + } + + $lockedDatabase = $config->getLockedDatabase(); + if (!$lockedDatabase) { + $database = strtolower(trim($database)); + if (!$state->isValidDatabaseAdapter($database)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Please select a supported database'); + return; + } + } + + $installId = $state->sanitizeInstallId($installId); + if ($installId === '') { + $installId = bin2hex(random_bytes(8)); + } + + @unlink(Server::INSTALLER_COMPLETE_FILE); + + try { + $lockResult = $state->reserveGlobalLock($installId); + } catch (\Throwable $e) { + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Lock failed: ' . $e->getMessage()]); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['success' => false, 'message' => 'Lock failed: ' . $e->getMessage()]); + } + return; + } + + if ($lockResult !== 'ok') { + $lockMessage = $lockResult === 'locked' + ? 'Installation already in progress' + : 'Installer lock unavailable'; + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => $lockMessage]); + $swooleResponse->end(); + } else { + $statusCode = $lockResult === 'locked' + ? Response::STATUS_CODE_CONFLICT + : Response::STATUS_CODE_SERVICE_UNAVAILABLE; + $response->setStatusCode($statusCode); + $response->json(['success' => false, 'message' => $lockMessage]); + } + return; + } + + $existingPath = $state->progressFilePath($installId); + $existing = null; + if (file_exists($existingPath)) { + $existing = $state->readProgressFile($installId); + if (!empty($existing['steps']) && $retryStep === null) { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_CONFLICT); + $response->json(['success' => false, 'message' => 'Installation already started']); + } + return; + } + } + + try { + $state->ensureBootstrapped(); + $installer = new \Appwrite\Platform\Tasks\Install(); + + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'install-id', ['installId' => $installId]); + } + + $state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + + $payloadInput = [ + '_APP_ENV' => 'production', + '_APP_OPENSSL_KEY_V1' => $opensslKey, + '_APP_DOMAIN' => $appDomain ?: 'localhost', + '_APP_DOMAIN_TARGET' => $appDomain ?: 'localhost', + '_APP_EMAIL_CERTIFICATES' => $emailCertificates, + '_APP_DB_ADAPTER' => $lockedDatabase ?? ($database ?: 'mongodb'), + '_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey, + ]; + + if ($this->hasPayload($existing)) { + $stored = $existing['payload']; + $inputValues = [ + 'httpPort' => (string) $httpPort, + 'httpsPort' => (string) $httpsPort, + 'database' => $database, + 'appDomain' => $appDomain, + 'emailCertificates' => $emailCertificates, + ]; + foreach ($inputValues as $field => $inputValue) { + if (isset($stored[$field]) && $inputValue !== '') { + $storedValue = (string) $stored[$field]; + if (in_array($field, ['httpPort', 'httpsPort'], true)) { + $storedValue = trim($storedValue); + $inputValue = trim($inputValue); + } + if ($storedValue !== $inputValue) { + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); + return; + } + } + } + + $sensitiveFields = [ + 'opensslKey' => ['hash' => 'opensslKeyHash', 'value' => $opensslKey], + 'assistantOpenAIKey' => ['hash' => 'assistantOpenAIKeyHash', 'value' => $assistantOpenAIKey], + ]; + foreach ($sensitiveFields as $field => $info) { + $hashField = $info['hash']; + $incomingValue = $info['value']; + if (!isset($stored[$hashField]) && !isset($stored[$field])) { + continue; + } + $incomingHash = $state->hashSensitiveValue($incomingValue); + if (isset($stored[$hashField])) { + if (!hash_equals((string) $stored[$hashField], $incomingHash)) { + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); + return; + } + } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) { + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); + return; + } + } + + $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']); + $httpPort = (int) ($stored['httpPort'] ?? $httpPort ?: $config->getDefaultHttpPort()); + $httpsPort = (int) ($stored['httpsPort'] ?? $httpsPort ?: $config->getDefaultHttpsPort()); + } + + $vars = $config->getVars(); + $shouldGenerateSecrets = !$installer->hasExistingConfig() && !$config->isUpgrade(); + $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars, $shouldGenerateSecrets); + + $state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => $httpPort ?: $config->getDefaultHttpPort(), + 'httpsPort' => $httpsPort ?: $config->getDefaultHttpsPort(), + 'database' => $lockedDatabase ?? ($database ?: 'mongodb'), + 'appDomain' => $appDomain ?: 'localhost', + 'emailCertificates' => $emailCertificates, + 'opensslKeyHash' => $state->hashSensitiveValue($opensslKey), + 'assistantOpenAIKeyHash' => $state->hashSensitiveValue($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, $swooleResponse, $state) { + $payload = [ + 'installId' => $installId, + 'step' => $step, + 'status' => $status, + 'message' => $message, + 'updatedAt' => time(), + ]; + if (!empty($details)) { + $payload['details'] = $details; + } + $state->writeProgressFile($installId, $payload); + $state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'progress', $payload); + } + }; + + $installer->performInstallation( + $httpPort ?: $config->getDefaultHttpPort(), + $httpsPort ?: $config->getDefaultHttpsPort(), + $config->getOrganization(), + $config->getImage(), + $envVars, + $config->getNoStart(), + $progress, + $retryStep, + $config->isUpgrade(), + $account + ); + + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + $swooleResponse->write(": keepalive\n\n"); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + $swooleResponse->end(); + } else { + $response->json([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully', + ]); + } + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } catch (\Throwable $e) { + $this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state); + } + } + + private function writeSseEvent(SwooleResponse $swooleResponse, string $event, array $payload): void + { + $swooleResponse->write("event: $event\ndata: " . json_encode($payload) . "\n\n"); + } + + private function sendBadRequest(Response $response, SwooleResponse $swooleResponse, bool $wantsStream, string $message, string $step = Server::STEP_CONFIG_FILES): void + { + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => $message, 'step' => $step]); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => $message]); + } + } + + private function handleInstallationError(\Throwable $e, string $installId, bool $wantsStream, Response $response, SwooleResponse $swooleResponse, State $state): void + { + if ($installId !== '') { + $state->writeProgressFile($installId, [ + 'step' => Server::STATUS_ERROR, + 'status' => Server::STATUS_ERROR, + 'message' => $e->getMessage(), + 'details' => $this->buildErrorDetails($e), + 'updatedAt' => time(), + ]); + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [ + 'message' => $e->getMessage(), + 'details' => $this->buildErrorDetails($e) + ]); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['success' => false, 'message' => $e->getMessage()]); + } + } + + private function buildErrorDetails(\Throwable $e): array + { + return []; + } + + private function hasPayload(mixed $data): bool + { + return is_array($data) && isset($data['payload']) && is_array($data['payload']); + } + + private function deriveNameFromEmail(string $email): string + { + $parts = explode('@', $email); + $username = $parts[0] ?? ''; + $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); + return ucfirst($cleaned); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Shutdown.php b/src/Appwrite/Platform/Installer/Http/Installer/Shutdown.php new file mode 100644 index 0000000000..7828038ffe --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Shutdown.php @@ -0,0 +1,48 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/shutdown') + ->desc('Shutdown installer server') + ->inject('request') + ->inject('response') + ->inject('swooleServer') + ->callback($this->action(...)); + } + + public function action(Request $request, Response $response, ?SwooleServer $swooleServer): void + { + if (!Validate::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + + $response->json(['success' => true]); + + if ($swooleServer) { + Timer::after(self::SHUTDOWN_DELAY_SECONDS * 1000, function () use ($swooleServer) { + $swooleServer->shutdown(); + }); + } + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php new file mode 100644 index 0000000000..e53a501f4c --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -0,0 +1,65 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/install/status') + ->desc('Poll installation progress') + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->inject('response') + ->inject('installerState') + ->callback($this->action(...)); + } + + public function action(string $installId, Response $response, State $state): void + { + $installId = $state->sanitizeInstallId($installId); + if ($installId === '') { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Missing installId']); + return; + } + + $path = $state->progressFilePath($installId); + if (!file_exists($path)) { + $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); + $response->json(['success' => false, 'message' => 'Install not found']); + return; + } + + $data = $state->readProgressFile($installId); + if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + unset( + $data['payload']['opensslKey'], + $data['payload']['assistantOpenAIKey'], + $data['payload']['opensslKeyHash'], + $data['payload']['assistantOpenAIKeyHash'], + ); + } + // Strip sensitive data from step details + if (is_array($data) && isset($data['details']) && is_array($data['details'])) { + foreach ($data['details'] as $stepKey => &$stepDetails) { + if (is_array($stepDetails)) { + unset($stepDetails['sessionSecret'], $stepDetails['trace']); + } + } + unset($stepDetails); + } + $response->json(['success' => true, 'progress' => $data]); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Validate.php b/src/Appwrite/Platform/Installer/Http/Installer/Validate.php new file mode 100644 index 0000000000..9a2e0b4528 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Validate.php @@ -0,0 +1,45 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/validate') + ->desc('Validate CSRF token') + ->inject('request') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Request $request, Response $response): void + { + if (!self::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + $response->json(['success' => true]); + } + + public static function validateCsrf(Request $request): bool + { + $cookie = $request->getCookie(Server::CSRF_COOKIE); + $header = $request->getHeader('x-appwrite-installer-csrf'); + + return $cookie !== '' && $header !== '' && hash_equals($cookie, $header); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php new file mode 100644 index 0000000000..b7d45036f6 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/') + ->desc('Serve installer UI') + ->param('step', 1, new Integer(true), 'Step number (1-5)', true) + ->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true) + ->inject('request') + ->inject('response') + ->inject('installerConfig') + ->inject('installerPaths') + ->callback($this->action(...)); + } + + public function action(int $step, ?string $partial, Request $request, Response $response, Config $config, array $paths): void + { + $csrfToken = $this->makeCsrf($request, $response); + + $response->addHeader('Content-Security-Policy', implode('; ', Server::INSTALLER_CSP)); + + $vars = $config->getVars(); + $defaultHttpPort = $config->getDefaultHttpPort(); + $defaultHttpsPort = $config->getDefaultHttpsPort(); + $isUpgrade = $config->isUpgrade(); + $lockedDatabase = $config->getLockedDatabase(); + $isLocalInstall = $config->isLocal(); + + $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? ''; + if ($isLocalInstall && empty($defaultEmailCertificates)) { + $defaultEmailCertificates = 'walterobrien@example.com'; + } + + $step = max(1, min(5, $step)); + if ($isUpgrade && ($step === 2 || $step === 3)) { + $step = 4; + } + + $partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml"; + if (!is_file($partialFile)) { + $partialFile = $paths['views'] . '/installer/templates/steps/step-1.phtml'; + } + + if ($partial !== null) { + ob_start(); + include $partialFile; + $html = ob_get_clean(); + $response->html($html); + return; + } + + ob_start(); + include $paths['views'] . '/installer.phtml'; + $html = ob_get_clean(); + + $response->html($html); + } + + private function makeCsrf(Request $request, Response $response): string + { + $existing = $request->getCookie(Server::CSRF_COOKIE); + if ($existing !== '') { + return $existing; + } + + $token = bin2hex(random_bytes(16)); + $response->addCookie(Server::CSRF_COOKIE, $token, null, '/', null, null, true, Response::COOKIE_SAMESITE_STRICT); + return $token; + } +} diff --git a/src/Appwrite/Platform/Installer/Installer.php b/src/Appwrite/Platform/Installer/Installer.php new file mode 100644 index 0000000000..c5b7c31674 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Installer.php @@ -0,0 +1,13 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php new file mode 100644 index 0000000000..370d9479da --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -0,0 +1,205 @@ +containsKnownKeys($values)) { + $this->setVars($values); + return; + } + $this->apply($values); + } + + public function apply(array $values): void + { + if ($this->hasValidStringValue($values, 'defaultHttpPort')) { + $this->setDefaultHttpPort((string) $values['defaultHttpPort']); + } + if ($this->hasValidStringValue($values, 'defaultHttpsPort')) { + $this->setDefaultHttpsPort((string) $values['defaultHttpsPort']); + } + if ($this->hasValidStringValue($values, 'organization')) { + $this->setOrganization((string) $values['organization']); + } + if ($this->hasValidStringValue($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('hostPath', $values)) { + $hostPath = $values['hostPath']; + $this->setHostPath($hostPath !== null && $hostPath !== '' ? (string) $hostPath : null); + } + if ($this->hasValidStringValue($values, 'lockedDatabase')) { + $this->setLockedDatabase((string) $values['lockedDatabase']); + } + if (array_key_exists('vars', $values) && is_array($values['vars'])) { + $this->setVars($values['vars']); + } + } + + private function hasValidStringValue(array $values, string $key): bool + { + return array_key_exists($key, $values) && $values[$key] !== null && $values[$key] !== ''; + } + + 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, + '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 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..71d30eb236 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -0,0 +1,420 @@ +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(__DIR__, 5) . '/.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, self::CONFIG_FILE_PERMISSION); + } + + + public function ensureBootstrapped(): void + { + if ($this->bootstrapped) { + return; + } + + require_once __DIR__ . '/../../../../../app/init.php'; + $this->bootstrapped = true; + } + + public function sanitizeInstallId($value): string + { + if (!is_string($value)) { + return ''; + } + + $clean = preg_replace(self::PATTERN_INSTALL_ID_SANITIZE, '', $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(self::PATTERN_DIGITS_ONLY, $string)) { + return false; + } + $port = (int) $string; + return $port >= self::PORT_MIN && $port <= self::PORT_MAX; + } + + public function isValidEmailAddress(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; + } + + public function isValidPassword(string $value): bool + { + return strlen($value) >= 8 && preg_match(self::PATTERN_HAS_NON_WHITESPACE, $value) === 1; + } + + public function isValidSecretKey(string $value): bool + { + return $value !== '' && strlen($value) <= 64; + } + + public function isValidAccountName(string $value): bool + { + return trim($value) !== ''; + } + + 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(self::PATTERN_IPV6_WITH_PORT, $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', 'postgresql'], 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), LOCK_EX); + } + + private function parseEnvFile(string $contents): array + { + $vars = []; + foreach ((array) preg_split(self::PATTERN_LINE_BREAKS, $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 Server::INSTALLER_LOCK_FILE; + } + + 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'] > self::GLOBAL_LOCK_TIMEOUT_SECONDS) { + 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 new file mode 100644 index 0000000000..5c07b416a7 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Server.php @@ -0,0 +1,316 @@ +initPaths(); + + $this->state = new State($this->paths); + + if (PHP_SAPI === 'cli') { + $this->runCli(); + return; + } + } + + private function initPaths(): void + { + if (!empty($this->paths)) { + return; + } + + $root = dirname(__DIR__, 4); + $this->paths = [ + 'public' => $root . '/public', + 'views' => $root . '/app/views/install', + ]; + } + + private function runCli(): void + { + $opts = getopt('', ['upgrade', 'locked-database::', 'docker', 'clean', 'port::', 'ready-file::']); + $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); + } + } + } + if (isset($opts['upgrade'])) { + $cfg->setIsUpgrade(true); + } + if (!empty($opts['locked-database'])) { + $cfg->setLockedDatabase($opts['locked-database']); + } + $this->state->applyEnvConfig($cfg); + + $host = self::INSTALLER_WEB_HOST; + $port = !empty($opts['port']) ? (string) $opts['port'] : (string) self::INSTALLER_WEB_PORT; + $readyFile = !empty($opts['ready-file']) ? (string) $opts['ready-file'] : null; + + if (isset($opts['clean'])) { + $this->removeDockerInstallerContainer(self::DEFAULT_CONTAINER); + $this->cleanupWebInstallerFiles(); + exit(0); + } + + if (isset($opts['docker'])) { + $this->printInstallerUrl($host, $port); + $this->startDockerInstaller($opts); + } + + $this->printInstallerUrl($host, $port); + $this->startSwooleServer($host, (int) $port, $readyFile); + } + + private function printInstallerUrl(string $host, string $port): void + { + $displayHost = $host === self::INSTALLER_WEB_HOST ? 'localhost' : $host; + $url = "http://$displayHost:$port"; + fwrite(STDOUT, "Open $url" . PHP_EOL); + } + + private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void + { + // Preload static files into memory + $files = new Files(); + $files->load($this->paths['views']); + + // Register resources for dependency injection into actions + $config = $this->state->buildConfig(); + $paths = $this->paths; + $state = $this->state; + + Http::setResource('installerState', fn () => $state); + Http::setResource('installerConfig', fn () => $config); + Http::setResource('installerPaths', fn () => $paths); + + // Register routes via Utopia Platform + $platform = new Installer(); + $platform->init(Service::TYPE_HTTP); + + // Register error handler directly so Http::error() preserves the '*' group + $errorHandler = new Error(); + Http::error() + ->inject('error') + ->inject('response') + ->action($errorHandler->action(...)); + + $adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter { + public function getNativeServer(): SwooleServer + { + return $this->server; + } + }; + + $nativeServer = $adapter->getNativeServer(); + + Http::setResource('swooleServer', fn () => $nativeServer); + + $nativeServer->on('start', function () use ($nativeServer, $port, $readyFile) { + \Swoole\Process::signal(SIGTERM, fn () => $nativeServer->shutdown()); + \Swoole\Process::signal(SIGINT, fn () => $nativeServer->shutdown()); + + if ($readyFile !== null) { + file_put_contents($readyFile, json_encode(['port' => $port, 'pid' => getmypid()])); + } + }); + + $adapter->onRequest(function (Request $request, Response $response) use ($files) { + // Serve static files from memory + $uri = $request->getURI(); + if ($files->isFileLoaded($uri)) { + $response + ->setContentType($files->getFileMimeType($uri)) + ->send($files->getFileContents($uri)); + return; + } + + $app = new Http('UTC'); + $app->run($request, $response); + }); + + $adapter->start(); + } + + private function removeDockerInstallerContainer(string $container): void + { + $name = escapeshellarg($container); + exec("docker rm -f $name >/dev/null 2>&1"); + } + + private function cleanupWebInstallerFiles(): void + { + $cwd = getcwd(); + if ($cwd === false) { + return; + } + + $filesToRemove = [ + $cwd . '/.env.web-installer', + $cwd . '/docker-compose.web-installer.yml', + ]; + + foreach ($filesToRemove as $file) { + if (file_exists($file)) { + @unlink($file); + } + } + + $tempDir = sys_get_temp_dir(); + @unlink(self::INSTALLER_LOCK_FILE); + @unlink(self::INSTALLER_CONFIG_FILE); + foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) { + @unlink($file); + } + } + + private function dockerImageExists(string $image): bool + { + $result = 1; + exec("docker image inspect " . escapeshellarg($image) . " >/dev/null 2>&1", $output, $result); + return $result === 0; + } + + 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); + } + } + + 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); + } + } + + 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'; + + $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['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, + '--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; + + $command = implode(' ', array_map(escapeshellarg(...), $args)); + passthru($command, $status); + exit($status); + } +} + +/** + * Run server only on direct CLI execution. + */ +function shouldRunInstallerServer(): bool +{ + return PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === realpath(__FILE__); +} + +if (shouldRunInstallerServer()) { + $server = new Server(); + $server->run(); +} diff --git a/src/Appwrite/Platform/Installer/Services/Http.php b/src/Appwrite/Platform/Installer/Services/Http.php new file mode 100644 index 0000000000..bd0fc62cdc --- /dev/null +++ b/src/Appwrite/Platform/Installer/Services/Http.php @@ -0,0 +1,26 @@ +type = Service::TYPE_HTTP; + + $this->addAction(View::getName(), new View()); + $this->addAction(Status::getName(), new Status()); + $this->addAction(Validate::getName(), new Validate()); + $this->addAction(Complete::getName(), new Complete()); + $this->addAction(Shutdown::getName(), new Shutdown()); + $this->addAction(Install::getName(), new Install()); + } +} diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php new file mode 100644 index 0000000000..f631015654 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php @@ -0,0 +1,82 @@ + 2) { + return false; + } + if (count($parts) === 2) { + [$host, $port] = $parts; + } + } + + if ($port !== null && $port !== '') { + $portInt = (int) $port; + if ((string) $portInt !== $port || $portInt < 1 || $portInt > 65535) { + return false; + } + } + + return $this->isValidDomain($host); + } + + private function isValidDomain(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/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 8784d30667..10de481072 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 @@ -103,11 +103,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 3384153971..a7d77d8a93 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; @@ -212,6 +213,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/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index 1dd9487ec7..eb71e5a02f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -218,9 +218,13 @@ class Create extends Action $dbForProject->setDatabase(APP_DATABASE); if ($sharedTables) { + $tenant = null; + if ($sharedTablesV1) { + $tenant = $project->getSequence(); + } $dbForProject ->setSharedTables(true) - ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null) + ->setTenant($tenant) ->setNamespace($dsn->getParam('namespace')); } else { $dbForProject @@ -272,14 +276,37 @@ class Create extends Action try { $dbForProject->createCollection($key, $attributes, $indexes); } catch (Duplicate) { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); + try { + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom($key), + '$permissions' => [Permission::create(Role::any())], + 'name' => $key, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } catch (Duplicate) { + // Metadata already exists from concurrent creation + } + } catch (\Throwable $e) { + // PostgreSQL adapter may throw a non-Duplicate exception when + // a table or index already exists during concurrent project + // creation in shared mode. Treat as duplicate if metadata + // can be created successfully. + try { + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom($key), + '$permissions' => [Permission::create(Role::any())], + 'name' => $key, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } catch (Duplicate) { + // Metadata already exists from concurrent creation + } catch (\Throwable) { + throw $e; // Rethrow original if metadata creation also fails + } } } } diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index ce9fca67ba..820bca46d5 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -4,17 +4,37 @@ 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; use Utopia\Config\Config; use Utopia\Console; +use Utopia\Fetch\Client; use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; 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; + + private const int HEALTH_CHECK_ATTEMPTS = 30; + private const int HEALTH_CHECK_DELAY_SECONDS = 3; + + private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/'; + private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/'; + private const string PATTERN_SESSION_COOKIE = '/a_session_console=([^;]+)/'; + + private const string APPWRITE_API_URL = 'http://appwrite'; + private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; + + protected string $hostPath = ''; + protected ?bool $isLocalInstall = null; + protected ?array $installerConfig = null; protected string $path = '/usr/src/code/appwrite'; public static function getName(): string @@ -32,16 +52,25 @@ class Install extends Action ->param('image', 'appwrite', new Text(0), 'Main appwrite docker image', true) ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) - ->param('database', 'mongodb', new Text(0), 'Database to use (mongodb|mariadb|postgres)', true) + ->param('database', 'mongodb', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database to use (mongodb|mariadb|postgresql)', true) ->callback($this->action(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, string $database): void - { + public function action( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + string $interactive, + bool $noStart, + string $database + ): void { + $isUpgrade = false; + $defaultHttpPort = '80'; + $defaultHttpsPort = '443'; $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) { @@ -52,6 +81,9 @@ class Install extends Action Console::success('Starting Appwrite installation...'); + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Create directory with write permissions if (!\file_exists(\dirname($this->path))) { if (!@\mkdir(\dirname($this->path), 0755, true)) { @@ -60,21 +92,16 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/docker-compose.yml'); - - 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; - } - } + // Check for existing installation + $data = $this->readExistingCompose(); + $envFileExists = file_exists($this->path . '/' . $this->getEnvFileName()); + $existingInstallation = $data !== '' || $envFileExists; + 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); + $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(); @@ -82,14 +109,14 @@ class Install extends Action $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { $ports = [ - $defaultHTTPPort => $defaultHTTPPort, - $defaultHTTPSPort => $defaultHTTPSPort + $defaultHttpPort => $defaultHttpPort, + $defaultHttpsPort => $defaultHttpsPort ]; Console::warning('Traefik not found. Falling back to default ports.'); } if ($oldVersion) { - foreach ($compose->getServices() as $service) { // Fetch all env vars from previous compose file + foreach ($compose->getServices() as $service) { if (!$service) { continue; } @@ -108,12 +135,14 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName()); - if ($data !== false) { // Fetch all env vars from previous .env file - Console::info('Env file found, creating backup: .env.' . $time . '.backup'); - file_put_contents($this->path . '/.env.' . $time . '.backup', $data); - $env = new Env($data); + if ($envData !== false) { + 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) { if (is_null($value)) { @@ -128,12 +157,12 @@ class Install extends Action } foreach ($ports as $key => $value) { - if ($value === $defaultHTTPPort) { - $defaultHTTPPort = $key; + if ($value === $defaultHttpPort) { + $defaultHttpPort = $key; } - if ($value === $defaultHTTPSPort) { - $defaultHTTPSPort = $key; + if ($value === $defaultHttpsPort) { + $defaultHttpsPort = $key; } } } @@ -147,30 +176,29 @@ 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:' . InstallerServer::INSTALLER_WEB_PORT); + Console::info('Press Ctrl+C to cancel installation'); - if (empty($httpPort)) { - $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); - $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; - } - - if (empty($httpsPort)) { - $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHTTPSPort . ')'); - $httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort; + $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars); + return; } + // Fall back to CLI mode $enableAssistant = false; $assistantExistsInOldCompose = false; - - if ($data !== false && isset($compose)) { + if ($existingInstallation && isset($compose)) { try { $assistantService = $compose->getService('appwrite-assistant'); $assistantExistsInOldCompose = $assistantService !== null; } catch (\Throwable) { - // assistant service doesn't exist, keep default false + /* ignore */ } } - if ($interactive == 'Y' && Console::isInteractive()) { + if ($interactive === 'Y' && Console::isInteractive()) { $prompt = 'Add Appwrite Assistant? (Y/n)' . ($assistantExistsInOldCompose ? ' [Currently enabled]' : ''); $answer = Console::confirm($prompt); @@ -183,148 +211,904 @@ class Install extends Action $enableAssistant = true; } - $input = []; + if (empty($httpPort)) { + $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')'); + $httpPort = ($httpPort) ?: $defaultHttpPort; + } - $password = new Password(); - $password->setCharset('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); - $token = new Token(); + if (empty($httpsPort)) { + $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHttpsPort . ')'); + $httpsPort = ($httpsPort) ?: $defaultHttpsPort; + } + + $userInput = []; foreach ($vars as $var) { if ($var['name'] === '_APP_ASSISTANT_OPENAI_API_KEY') { if (!$enableAssistant) { - $input[$var['name']] = ''; + $userInput[$var['name']] = ''; continue; } - // key already exists if (!empty($var['default'])) { - $input[$var['name']] = $var['default']; + $userInput[$var['name']] = $var['default']; continue; } - // if assistant enabled and no key, ask for it if (Console::isInteractive() && $interactive === 'Y') { - $input[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:'); - if (empty($input[$var['name']])) { + $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; - $input[$var['name']] = ''; + $userInput[$var['name']] = ''; } - continue; + } else { + $userInput[$var['name']] = ''; } - $input[$var['name']] = ''; continue; } - 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; } if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) { - $input[$var['name']] = $database; + $userInput[$var['name']] = $database; 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"); } } - $database = $input['_APP_DB_ADAPTER']; + $database = $userInput['_APP_DB_ADAPTER'] ?? $database; if ($database === 'postgresql') { - $input['_APP_DB_HOST'] = 'postgresql'; - $input['_APP_DB_PORT'] = 5432; + $userInput['_APP_DB_HOST'] = 'postgresql'; + $userInput['_APP_DB_PORT'] = 5432; + } elseif ($database === 'mongodb') { + $userInput['_APP_DB_HOST'] = 'mongodb'; + $userInput['_APP_DB_PORT'] = 27017; } elseif ($database === 'mariadb') { - $input['_APP_DB_HOST'] = 'mariadb'; - $input['_APP_DB_PORT'] = 3306; + $userInput['_APP_DB_HOST'] = 'mariadb'; + $userInput['_APP_DB_PORT'] = 3306; } - $database = $input['_APP_DB_ADAPTER']; + $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade; + $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); + } + + + protected function startWebServer(string $defaultHttpPort, string $defaultHttpsPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void + { + $port = InstallerServer::INSTALLER_WEB_PORT; + + @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); + + $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, + ]); + + // Start Swoole-based installer server in background + // Redirect stdout/stderr to a log file so exec() returns immediately + // (otherwise the backgrounded process holds the pipe open and exec() hangs) + $serverScript = \escapeshellarg(dirname(__DIR__) . '/Installer/Server.php'); + $logFile = \sys_get_temp_dir() . '/appwrite-installer-server.log'; + $output = []; + \exec("php {$serverScript} > " . \escapeshellarg($logFile) . " 2>&1 & echo \$!", $output); + $pid = isset($output[0]) ? (int) $output[0] : 0; + + \register_shutdown_function(function () use ($pid) { + if ($pid > 0 && \function_exists('posix_kill')) { + @\posix_kill($pid, SIGTERM); + } + }); + \sleep(1); + + if (!$this->waitForWebServer($port)) { + $log = @\file_get_contents($logFile); + if ($log !== false && $log !== '') { + Console::error('Installer server log:'); + Console::error($log); + } + Console::warning('Web installer did not respond in time. Please refresh the browser.'); + return; + } + + if ($this->isInstallationComplete($port)) { + Console::success('Installation completed.'); + } + } + + public function prepareEnvironmentVariables(array $userInput, array $vars, bool $shouldGenerateSecrets = true): array + { + $input = []; + $password = new Password(); + $token = new Token(); + + // Start with all defaults + foreach ($vars as $var) { + $filter = $var['filter'] ?? null; + $default = $var['default'] ?? null; + $hasDefault = $default !== null && $default !== ''; + + if ($filter === 'token') { + if ($hasDefault) { + $input[$var['name']] = $default; + } elseif ($shouldGenerateSecrets) { + $input[$var['name']] = $token->generate(); + } else { + $input[$var['name']] = ''; + } + } elseif ($filter === 'password') { + if ($hasDefault) { + $input[$var['name']] = $default; + } elseif ($shouldGenerateSecrets) { + /*;#+@:/?& broke DSNs locally */ + $input[$var['name']] = $this->generatePasswordValue($var['name'], $password); + } else { + $input[$var['name']] = ''; + } + } else { + $input[$var['name']] = $default; + } + } + + // Override with user inputs + foreach ($userInput as $key => $value) { + if ($value !== null && ($value !== '' || $key === '_APP_ASSISTANT_OPENAI_API_KEY')) { + $input[$key] = $value; + } + } + + 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') { $input['_APP_DB_HOST'] = 'mongodb'; $input['_APP_DB_PORT'] = 27017; } elseif ($database === 'mariadb') { $input['_APP_DB_HOST'] = 'mariadb'; $input['_APP_DB_PORT'] = 3306; + } elseif ($database === 'postgresql') { + $input['_APP_DB_HOST'] = 'postgresql'; + $input['_APP_DB_PORT'] = 5432; } - $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml'); - $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml'); - $templateForCompose - ->setParam('httpPort', $httpPort) - ->setParam('httpsPort', $httpsPort) - ->setParam('version', APP_VERSION_STABLE) - ->setParam('organization', $organization) - ->setParam('image', $image) - ->setParam('enableAssistant', $enableAssistant) - ->setParam('database', $database); + return $input; + } - $templateForEnv->setParam('vars', $input); + public function hasExistingConfig(): bool + { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); - 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 ($this->readExistingCompose() !== '') { + return true; } - if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - $message = 'Failed to save environment variables file'; - Console::error($message); - Console::exit(1); + return file_exists($this->path . '/' . $this->getEnvFileName()); + } + + private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void + { + if (!$progress) { + return; } - $env = ''; - $stdout = ''; - $stderr = ''; - - foreach ($input as $key => $value) { - if ($value) { - $env .= $key . '=' . \escapeshellarg($value) . ' '; + if ($messageOverride !== null) { + $message = $messageOverride; + } else { + $key = $status === InstallerServer::STATUS_COMPLETED ? 'done' : 'start'; + $message = $messages[$step][$key] ?? null; + if ($message === null) { + return; } } - $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); + try { + $progress($step, $status, $message, $details); + } catch (\Throwable $e) { } - - if ($exit !== 0) { - $message = 'Failed to install Appwrite dockers'; - Console::error($message); - Console::error($stderr); - Console::exit($exit); - } else { - $message = 'Appwrite installed successfully'; - Console::success($message); + 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, + string $organization, + string $image, + array $input, + bool $noStart, + ?callable $progress = null, + ?string $resumeFromStep = null, + bool $isUpgrade = false, + array $account = [] + ): void { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, false); + + $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) { + $image = 'appwrite'; + $organization = 'appwrite'; + } + + $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 = \getenv('_APP_VERSION') ?: (\defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest'); + 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('hostPath', $this->hostPath) + ->setParam('enableAssistant', $enableAssistant); + + $templateForEnv->setParam('vars', $input); + + $steps = [ + InstallerServer::STEP_DOCKER_COMPOSE, + InstallerServer::STEP_ENV_VARS, + InstallerServer::STEP_DOCKER_CONTAINERS + ]; + + $startIndex = 0; + if ($resumeFromStep !== null) { + $resumeIndex = array_search($resumeFromStep, $steps, true); + if ($resumeIndex !== false) { + $startIndex = $resumeIndex; + } + } + + $currentStep = null; + + $messages = $this->buildStepMessages($isUpgrade); + + try { + if ($startIndex <= 1) { + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_IN_PROGRESS, $messages); + } + + if ($startIndex <= 0) { + $currentStep = InstallerServer::STEP_DOCKER_COMPOSE; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages); + + if (!$useExistingConfig) { + $this->writeComposeFile($templateForCompose); + } + + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages); + } + + if ($startIndex <= 1) { + $currentStep = InstallerServer::STEP_ENV_VARS; + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages); + + if (!$useExistingConfig) { + $this->writeEnvFile($templateForEnv); + } + + $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) { + $this->copyMongoEntrypointIfNeeded(); + } + + if (!$noStart && $startIndex <= 2) { + $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI); + + if (!$isLocalInstall) { + $this->connectInstallerToAppwriteNetwork(); + } + + $domain = $input['_APP_DOMAIN'] ?? 'localhost'; + + // Wait for Appwrite API to be healthy before marking containers as ready + $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS); + + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + + if (!$isUpgrade) { + $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); + } + + // Track installs + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + + if ($isCLI) { + Console::success('Appwrite installed successfully'); + } + } else { + if ($isCLI) { + Console::success('Installation files created. Run "docker compose up -d" to start Appwrite'); + } + } + } catch (\Throwable $e) { + if ($currentStep) { + $details = []; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + $this->updateProgress($progress, $currentStep, InstallerServer::STATUS_ERROR, $messages, $details, $e->getMessage()); + } + throw $e; + } + } + + private function createInitialAdminAccount(array $account, ?callable $progress, string $apiUrl, string $domain): void + { + $name = $account['name'] ?? 'Admin'; + $email = $account['email'] ?? null; + $password = $account['password'] ?? null; + + if (!$email || !$password) { + return; + } + + try { + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_IN_PROGRESS, + messageOverride: 'Creating Appwrite account' + ); + + // Create the account — tolerate "already exists" so we can still + // create a session (common when re-running the installer). + $userId = null; + try { + $userId = $this->makeApiCall('/v1/account', [ + 'userId' => 'unique()', + 'email' => $email, + 'password' => $password, + 'name' => $name + ], false, $apiUrl, $domain); + } catch (\Throwable $e) { + if (\stripos($e->getMessage(), 'already exists') === false) { + throw $e; + } + } + + $session = $this->makeApiCall('/v1/account/sessions/email', [ + 'email' => $email, + 'password' => $password + ], true, $apiUrl, $domain); + + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_COMPLETED, + details: [ + 'userId' => $userId ?? $session['id'], + 'sessionId' => $session['id'], + 'sessionSecret' => $session['secret'], + 'sessionExpire' => $session['expire'] ?? null + ], + messageOverride: 'Account created successfully' + ); + } catch (\Throwable $e) { + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_ERROR, + details: [ + 'output' => "apiUrl={$apiUrl}, domain={$domain}", + 'trace' => $e->getTraceAsString(), + ], + messageOverride: 'Account creation failed: ' . $e->getMessage() + ); + } + } + + private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void + { + if ($this->isLocalInstall()) { + return; + } + + $appEnv = $input['_APP_ENV'] ?? 'development'; + $domain = $input['_APP_DOMAIN'] ?? 'localhost'; + + /* local or test instance */ + if ($appEnv !== 'production') { + return; + } + + /* prod but local or test instance */ + if ($domain === 'localhost' + || str_starts_with($domain, '127.') + || str_starts_with($domain, '0.0.0.0') + ) { + return; + } + + $type = $isUpgrade ? 'upgrade' : 'install'; + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + $name = $account['name'] ?? 'Admin'; + $email = $account['email'] ?? 'admin@selfhosted.local'; + + $payload = [ + 'action' => $type, + 'account' => 'self-hosted', + 'url' => 'https://' . $domain, + 'category' => 'self_hosted', + 'label' => 'self_hosted_' . $type, + 'version' => $version, + 'data' => json_encode([ + 'name' => $name, + 'email' => $email, + 'domain' => $domain, + 'database' => $database, + ]), + ]; + + try { + $client = new Client(); + $client + ->addHeader('Content-Type', 'application/json') + ->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload); + } catch (\Throwable) { + // tracking shouldn't block installation + } + } + + /** + * Wait for the Appwrite API to respond. Builds candidate URLs based on + * the runtime context and returns whichever responds first. + * + * Candidates (in order of preference): + * - Docker internal DNS (http://appwrite) — only if on the appwrite network + * - host.docker.internal:{port} — reaches host-published ports from inside a container + * - localhost:{port} — works when running directly on the host (local dev) + */ + private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string + { + $client = new Client(); + $client + ->setTimeout(5000) + ->setConnectTimeout(5000) + ->addHeader('Host', $domain); + + $healthPath = '/v1/health/version'; + + // Local dev: reach Traefik via localhost on the host. + // Docker: reach Appwrite directly via Docker internal DNS (network connect is guaranteed). + $candidate = $isLocalInstall + ? 'http://localhost:' . $httpPort . $healthPath + : self::APPWRITE_API_URL . $healthPath; + $candidates = [$candidate]; + + $lastErrors = []; + + for ($i = 0; $i < self::HEALTH_CHECK_ATTEMPTS; $i++) { + foreach ($candidates as $url) { + try { + $response = $client->fetch($url); + if ($response->getStatusCode() === 200) { + return \rtrim(\substr($url, 0, -\strlen($healthPath)), '/'); + } + $lastErrors[$url] = "HTTP {$response->getStatusCode()}"; + } catch (\Throwable $e) { + $lastErrors[$url] = $e->getMessage(); + } + } + + if ($progress) { + try { + $progress( + $step, + InstallerServer::STATUS_IN_PROGRESS, + 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')', + [] + ); + } catch (\Throwable) { + } + } + + if ($i < self::HEALTH_CHECK_ATTEMPTS - 1) { + sleep(self::HEALTH_CHECK_DELAY_SECONDS); + } + } + + $errorDetail = implode('; ', array_map( + fn ($url, $err) => "{$url} => {$err}", + array_keys($lastErrors), + array_values($lastErrors) + )); + + throw new \Exception("Failed to connect with Appwrite in time. Tried: {$errorDetail}"); + } + + /** + * Connect the installer container to the appwrite network, retrying + * until it succeeds or we run out of attempts. + */ + private function connectInstallerToAppwriteNetwork(): void + { + $network = escapeshellarg('appwrite'); + + // Resolve our own container ID — works regardless of how the + // container was started (--name, random name, etc.). + $containerId = trim((string) @file_get_contents('/etc/hostname')); + if ($containerId === '') { + $containerId = InstallerServer::DEFAULT_CONTAINER; + } + $container = escapeshellarg($containerId); + + $outputStr = ''; + for ($i = 0; $i < 10; $i++) { + $output = []; + @exec("docker network connect {$network} {$container} 2>&1", $output, $exitCode); + + if ($exitCode === 0) { + return; + } + + $outputStr = implode(' ', $output); + if (str_contains($outputStr, 'already exists')) { + return; + } + + sleep(1); + } + + throw new \Exception('Failed to connect installer to appwrite network: ' . $outputStr); + } + + private function makeApiCall(string $endpoint, array $body, bool $extractSession = false, string $apiUrl = self::APPWRITE_API_URL, string $domain = 'localhost') + { + $client = new Client(); + $client + ->setTimeout(30000) + ->setConnectTimeout(10000) + ->addHeader('Content-Type', 'application/json') + ->addHeader('X-Appwrite-Project', 'console') + ->addHeader('Host', $domain); + + $url = $apiUrl . $endpoint; + $response = $client->fetch($url, Client::METHOD_POST, $body); + + if ($response->getStatusCode() !== 201) { + $error = $response->json(); + $message = $error['message'] ?? ('HTTP ' . $response->getStatusCode() . ': ' . $response->getBody()); + throw new \Exception("API call failed ({$endpoint}): {$message}"); + } + + $data = $response->json(); + if (!isset($data['$id'])) { + throw new \Exception('API response missing ID field'); + } + + if ($extractSession) { + $headers = $response->getHeaders(); + $setCookie = $headers['set-cookie'] ?? $headers['Set-Cookie'] ?? null; + + if (!$setCookie || !preg_match(self::PATTERN_SESSION_COOKIE, $setCookie, $matches)) { + throw new \Exception('Session created but no cookie found'); + } + + return [ + 'id' => $data['$id'], + 'secret' => urldecode($matches[1]), + 'expire' => $data['expire'] ?? null + ]; + } + + return $data['$id']; + } + + 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): void + { + $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): void + { + $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(self::PATTERN_ENV_VAR_NAME, $key)) { + throw new \Exception("Invalid environment variable name: $key"); + } + $env .= $key . '=' . \escapeshellarg((string) $value) . ' '; + } + } + + if ($isCLI) { + Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); + } + + $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'; + $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 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 + { + 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(self::PATTERN_DB_PASSWORD_VAR, $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 isInstallationComplete(int $port): bool + { + $maxAttempts = 7200; // 2 hours maximum + $attempt = 0; + while ($attempt < $maxAttempts) { + if (file_exists(InstallerServer::INSTALLER_COMPLETE_FILE)) { + return true; + } + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle === false) { + return false; + } + \fclose($handle); + \sleep(1); + $attempt++; + } + return false; + } + + 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 2e77ddd885..200d214ead 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -4,13 +4,14 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Docker\Compose; use Appwrite\Docker\Env; -use Utopia\Console; -use Utopia\System\System; +use Utopia\CLI\Console; use Utopia\Validator\Boolean; use Utopia\Validator\Text; class Upgrade extends Install { + private ?string $lockedDatabase = null; + public static function getName(): string { return 'upgrade'; @@ -18,6 +19,8 @@ class Upgrade extends Install public function __construct() { + parent::__construct(); + $this ->desc('Upgrade Appwrite') ->param('http-port', '', new Text(4), 'Server HTTP port', true) @@ -30,20 +33,31 @@ class Upgrade extends Install ->callback($this->action(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, string $database): void - { + public function action( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + string $interactive, + bool $noStart, + string $database + ): void { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Check for previous installation - $data = @file_get_contents($this->path . '/docker-compose.yml'); + $data = $this->readExistingCompose(); if (empty($data)) { 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); + Console::log(' └── ' . $this->getComposeFileName()); + return; } + // Detect database from existing installation (CLI param is intentionally ignored) $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { @@ -66,10 +80,33 @@ class Upgrade extends Install } if ($database === null) { - // TODO: Change default to 'mongodb' after next release - $database = System::getEnv('_APP_DB_ADAPTER', 'mariadb'); + throw new \Exception('Database type not found, can not upgrade. Ensure `_APP_DB_ADAPTER` is set in your environment.'); } + $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/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 29ccb0ef09..3f19abdf22 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -513,7 +513,7 @@ class Functions extends Action $command = $runtime['startCommand']; if (!empty($deployment->getAttribute('startCommand', ''))) { - $command = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + $command = 'cd /usr/local/server/src/function/ && ' . str_replace(['"', '`', '$'], ['\\"', '\\`', '\\$'], $deployment->getAttribute('startCommand', '')); } $source = $deployment->getAttribute('buildPath', ''); diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 07051d1f15..76be33d06b 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -479,7 +479,8 @@ class StatsUsage extends Action } } $documentClone = clone $stat; - $documentClone->setAttribute('$tenant', (int) $project->getSequence()); + $dbForLogs = ($this->getLogsDB)(); + $documentClone->setAttribute('$tenant', $project->getSequence()); $this->statDocuments[] = $documentClone; } diff --git a/src/Appwrite/Utopia/Response/Filters/V21.php b/src/Appwrite/Utopia/Response/Filters/V21.php index 4a414a9418..37f9e01b86 100644 --- a/src/Appwrite/Utopia/Response/Filters/V21.php +++ b/src/Appwrite/Utopia/Response/Filters/V21.php @@ -10,8 +10,6 @@ class V21 extends Filter { public function parse(array $content, string $model): array { - $parsedResponse = $content; - return match ($model) { Response::MODEL_SITE => $this->parseSite($content), Response::MODEL_SITE_LIST => $this->handleList( @@ -25,7 +23,7 @@ class V21 extends Filter "functions", fn ($item) => $this->parseFunction($item), ), - default => $parsedResponse, + default => $content, }; } diff --git a/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php b/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php index 499f2ee265..d6869cc650 100644 --- a/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php +++ b/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php @@ -59,7 +59,7 @@ trait DatabasesPermissionsBase 'password' => $password ]); - $this->assertEquals(201, $user['headers']['status-code']); + $this->assertContains($user['headers']['status-code'], [201, 409]); $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ 'origin' => 'http://localhost', @@ -72,9 +72,12 @@ trait DatabasesPermissionsBase $session = $session['cookies']['a_session_' . $this->getProject()['$id']]; + $userId = $user['headers']['status-code'] === 201 ? $user['body']['$id'] : $id; + $userEmail = $user['headers']['status-code'] === 201 ? $user['body']['email'] : $email; + $user = [ - '$id' => $user['body']['$id'], - 'email' => $user['body']['email'], + '$id' => $userId, + 'email' => $userEmail, 'session' => $session, ]; $this->users[$id] = $user; @@ -94,6 +97,12 @@ trait DatabasesPermissionsBase 'teamId' => $id, 'name' => $name ]); + $this->assertContains($team['headers']['status-code'], [201, 409]); + + if ($team['headers']['status-code'] === 409) { + $team = $this->client->call(Client::METHOD_GET, '/teams/' . $id, $this->getServerHeader()); + } + $this->teams[$id] = $team['body']; return $team['body']; diff --git a/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php b/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php index c0d880e739..b5c5e854a1 100644 --- a/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php +++ b/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php @@ -22,7 +22,7 @@ class LegacyPermissionsTeamTest extends Scope use SchemaPolling; public array $collections = []; - public string $databaseId = 'testpermissiondb'; + public string $databaseId = 'testpermdb_legacy'; public function createTeams(): array { diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index c5fb71fcb6..d1d7d0d054 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -2289,7 +2289,7 @@ class RealtimeCustomClientTest extends Scope ]); $this->assertEquals('ready', $deployment['body']['status'], \json_encode($deployment['body'])); - }); + }, 240_000, 500); $response = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([ 'content-type' => 'application/json', @@ -3743,7 +3743,51 @@ class RealtimeCustomClientTest extends Scope $session = $user['session'] ?? ''; $projectId = $this->getProject()['$id']; - Coroutine\run(function () use ($session, $projectId) { + // Setup DB/collection/attribute outside coroutine to avoid fatal errors on assertion failure + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Concurrent DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Concurrent Collection', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 64, + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status'] ?? null); + }, 30000, 250); + + Coroutine\run(function () use ($session, $projectId, $databaseId, $collectionId) { $headers = [ 'origin' => 'http://localhost', 'cookie' => 'a_session_' . $projectId . '=' . $session @@ -3760,50 +3804,6 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('connected', $response['type']); } - // Setup DB/collection/attribute - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Concurrent DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Concurrent Collection', - 'permissions' => [ - Permission::create(Role::user($this->getUser()['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 64, - 'required' => true, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - $creates = [ ['name' => 'Doc A'], ['name' => 'Doc B'], diff --git a/tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php b/tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php index ca6248a228..8c000d99b3 100644 --- a/tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php +++ b/tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php @@ -23,7 +23,7 @@ class TablesDBPermissionsTeamTest extends Scope use SchemaPolling; public array $collections = []; - public string $databaseId = 'testpermissiondb'; + public string $databaseId = 'testpermdb_tablesdb'; public function createTeams(): array { diff --git a/tests/unit/Functions/Validator/HeadersBench.php b/tests/unit/Functions/Validator/HeadersBench.php deleted file mode 100644 index f95fa65f9b..0000000000 --- a/tests/unit/Functions/Validator/HeadersBench.php +++ /dev/null @@ -1,60 +0,0 @@ -validator = new Headers(); - } - - public function providers(): iterable - { - yield 'empty' => [ 'value' => [] ]; - - $value = []; - for ($i = 0; $i < 10; $i++) { - $value[bin2hex(random_bytes(8))] = bin2hex(random_bytes(8)); - } - yield 'items_10-size_320' => [ 'value' => $value ]; - - $value = []; - for ($i = 0; $i < 100; $i++) { - $value[bin2hex(random_bytes(8))] = bin2hex(random_bytes(8)); - } - yield 'items_100-size_3200' => [ 'value' => $value ]; - - $value = []; - for ($i = 0; $i < 100; $i++) { - $value[bin2hex(random_bytes(32))] = bin2hex(random_bytes(32)); - } - yield 'items_100-size_12800' => [ 'value' => $value ]; - } - - #[BeforeMethods('prepare')] - #[AfterMethods('tearDown')] - #[ParamProviders('providers')] - #[Iterations(50)] - #[Assert('mode(variant.time.avg) < 1 ms')] - public function benchHeadersValidator(array $data): void - { - $assertion = $this->validator->isValid($data['value']); - if (!$assertion) { - exit(1); - } - } -} diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php new file mode 100644 index 0000000000..8df452d8de --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -0,0 +1,291 @@ +module = new Module(); + } + + protected function tearDown(): void + { + $this->module = null; + } + + public function testModuleHasHttpService(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $this->assertCount(1, $services); + } + + public function testHttpServiceRegistersAllActions(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + + $this->assertCount(6, $actions); + $this->assertArrayHasKey('installerView', $actions); + $this->assertArrayHasKey('installerStatus', $actions); + $this->assertArrayHasKey('installerValidate', $actions); + $this->assertArrayHasKey('installerComplete', $actions); + $this->assertArrayHasKey('installerShutdown', $actions); + $this->assertArrayHasKey('installerInstall', $actions); + } + + public function testViewAction(): void + { + $action = $this->getAction('installerView'); + + $this->assertEquals('installerView', View::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_GET, $action->getHttpMethod()); + $this->assertEquals('/', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['step', 'partial']); + $this->assertActionInjects($action, ['request', 'response', 'installerConfig', 'installerPaths']); + } + + public function testStatusAction(): void + { + $action = $this->getAction('installerStatus'); + + $this->assertEquals('installerStatus', Status::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_GET, $action->getHttpMethod()); + $this->assertEquals('/install/status', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['installId']); + $this->assertActionInjects($action, ['response', 'installerState']); + } + + public function testValidateAction(): void + { + $action = $this->getAction('installerValidate'); + + $this->assertEquals('installerValidate', Validate::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/validate', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response']); + } + + public function testCompleteAction(): void + { + $action = $this->getAction('installerComplete'); + + $this->assertEquals('installerComplete', Complete::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/complete', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['installId', 'sessionId', 'sessionSecret', 'sessionExpire']); + $this->assertActionInjects($action, ['request', 'response', 'installerState']); + } + + public function testShutdownAction(): void + { + $action = $this->getAction('installerShutdown'); + + $this->assertEquals('installerShutdown', Shutdown::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/shutdown', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response', 'swooleServer']); + } + + public function testInstallAction(): void + { + $action = $this->getAction('installerInstall'); + + $this->assertEquals('installerInstall', Install::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, [ + 'appDomain', 'httpPort', 'httpsPort', 'emailCertificates', 'opensslKey', + 'assistantOpenAIKey', 'accountEmail', 'accountPassword', 'database', + 'installId', 'retryStep', + ]); + $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); + } + + public function testErrorActionClass(): void + { + $error = new Error(); + + $this->assertEquals('installerError', Error::getName()); + $this->assertEquals(Action::TYPE_ERROR, $error->getType()); + $this->assertIsCallable($error->getCallback()); + } + + /** + * @runInSeparateProcess + */ + public function testRouteRegistration(): void + { + $platform = new class (new Module()) extends Platform {}; + $platform->init(Service::TYPE_HTTP); + + // If we get here without exceptions, route registration succeeded + $this->assertTrue(true); + } + + public function testModuleHasNoTaskServices(): void + { + $services = $this->module->getServicesByType(Service::TYPE_TASK); + $this->assertEmpty($services); + } + + public function testModuleHasNoWorkerServices(): void + { + $services = $this->module->getServicesByType(Service::TYPE_WORKER); + $this->assertEmpty($services); + } + + public function testAllDefaultActionsHaveDescriptions(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + foreach ($service->getActions() as $name => $action) { + $desc = $action->getDesc(); + $this->assertNotNull($desc, "Action '$name' should have a description"); + $this->assertNotEmpty($desc, "Action '$name' description should not be empty"); + } + } + + public function testAllActionsHaveCallableCallbacks(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + foreach ($service->getActions() as $name => $action) { + $callback = $action->getCallback(); + $this->assertIsCallable($callback, "Action '$name' callback should be callable"); + } + } + + public function testActionNamesAreUnique(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + $names = array_keys($actions); + $this->assertEquals($names, array_unique($names)); + } + + public function testRoutePathsAreUniquePerMethod(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $routes = []; + foreach ($service->getActions() as $action) { + $key = $action->getHttpMethod() . ' ' . $action->getHttpPath(); + $this->assertArrayNotHasKey($key, $routes, "Duplicate route: $key"); + $routes[$key] = true; + } + } + + public function testStaticGetNameValues(): void + { + $this->assertEquals('installerView', View::getName()); + $this->assertEquals('installerStatus', Status::getName()); + $this->assertEquals('installerValidate', Validate::getName()); + $this->assertEquals('installerComplete', Complete::getName()); + $this->assertEquals('installerShutdown', Shutdown::getName()); + $this->assertEquals('installerInstall', Install::getName()); + $this->assertEquals('installerError', Error::getName()); + } + + public function testActionInstanceTypes(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + + $this->assertInstanceOf(View::class, $actions['installerView']); + $this->assertInstanceOf(Status::class, $actions['installerStatus']); + $this->assertInstanceOf(Validate::class, $actions['installerValidate']); + $this->assertInstanceOf(Complete::class, $actions['installerComplete']); + $this->assertInstanceOf(Shutdown::class, $actions['installerShutdown']); + $this->assertInstanceOf(Install::class, $actions['installerInstall']); + } + + public function testGetRoutesUseGetMethod(): void + { + $getActions = ['installerView', 'installerStatus']; + foreach ($getActions as $name) { + $action = $this->getAction($name); + $this->assertEquals( + Action::HTTP_REQUEST_METHOD_GET, + $action->getHttpMethod(), + "Action '$name' should use GET method" + ); + } + } + + public function testPostRoutesUsePostMethod(): void + { + $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerInstall']; + foreach ($postActions as $name) { + $action = $this->getAction($name); + $this->assertEquals( + Action::HTTP_REQUEST_METHOD_POST, + $action->getHttpMethod(), + "Action '$name' should use POST method" + ); + } + } + + public function testValidateClassHasCsrfMethod(): void + { + $this->assertTrue( + method_exists(Validate::class, 'validateCsrf'), + 'Validate class should expose validateCsrf method' + ); + } + + private function getAction(string $name): Action + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + $this->assertArrayHasKey($name, $actions); + return $actions[$name]; + } + + private function assertActionInjects(Action $action, array $expectedInjections): void + { + $injections = []; + foreach ($action->getOptions() as $option) { + if ($option['type'] === 'injection') { + $injections[] = $option['name']; + } + } + $this->assertEquals($expectedInjections, $injections); + } + + private function assertActionParams(Action $action, array $expectedParams): void + { + $params = []; + foreach ($action->getOptions() as $key => $option) { + if ($option['type'] === 'param') { + $params[] = substr($key, 6); // strip 'param:' prefix + } + } + $this->assertEquals($expectedParams, $params); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php b/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php new file mode 100644 index 0000000000..33cab8de22 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php @@ -0,0 +1,488 @@ +assertEquals('80', $config->getDefaultHttpPort()); + $this->assertEquals('443', $config->getDefaultHttpsPort()); + $this->assertEquals('appwrite', $config->getOrganization()); + $this->assertEquals('appwrite', $config->getImage()); + $this->assertFalse($config->getNoStart()); + $this->assertFalse($config->isUpgrade()); + $this->assertFalse($config->isLocal()); + $this->assertNull($config->getHostPath()); + $this->assertNull($config->getLockedDatabase()); + $this->assertEmpty($config->getVars()); + } + + public function testConstructorWithKnownKeys(): void + { + $config = new Config([ + 'defaultHttpPort' => '8080', + 'isUpgrade' => true, + 'organization' => 'myorg', + ]); + + $this->assertEquals('8080', $config->getDefaultHttpPort()); + $this->assertTrue($config->isUpgrade()); + $this->assertEquals('myorg', $config->getOrganization()); + } + + public function testConstructorWithUnknownKeysTreatsAsVars(): void + { + $vars = [ + '_APP_ENV' => 'production', + '_APP_DOMAIN' => 'example.com', + ]; + $config = new Config($vars); + + $this->assertEquals($vars, $config->getVars()); + // Defaults should remain + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testApplyAllFields(): void + { + $config = new Config(); + $config->apply([ + 'defaultHttpPort' => '3000', + 'defaultHttpsPort' => '3443', + 'organization' => 'testorg', + 'image' => 'testimage', + 'noStart' => true, + 'isUpgrade' => true, + 'isLocal' => true, + 'hostPath' => '/home/user', + 'lockedDatabase' => 'mariadb', + 'vars' => [['name' => '_APP_ENV', 'default' => 'production']], + ]); + + $this->assertEquals('3000', $config->getDefaultHttpPort()); + $this->assertEquals('3443', $config->getDefaultHttpsPort()); + $this->assertEquals('testorg', $config->getOrganization()); + $this->assertEquals('testimage', $config->getImage()); + $this->assertTrue($config->getNoStart()); + $this->assertTrue($config->isUpgrade()); + $this->assertTrue($config->isLocal()); + $this->assertEquals('/home/user', $config->getHostPath()); + $this->assertEquals('mariadb', $config->getLockedDatabase()); + $this->assertCount(1, $config->getVars()); + } + + public function testApplyIgnoresNullAndEmptyStringValues(): void + { + $config = new Config(['defaultHttpPort' => '9090']); + + $config->apply(['defaultHttpPort' => '']); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => null]); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testApplyHostPathCanBeSetToNull(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + $this->assertEquals('/some/path', $config->getHostPath()); + + $config->apply(['hostPath' => null]); + $this->assertNull($config->getHostPath()); + } + + public function testApplyPartialUpdate(): void + { + $config = new Config([ + 'defaultHttpPort' => '8080', + 'defaultHttpsPort' => '8443', + 'organization' => 'original', + ]); + + $config->apply(['organization' => 'updated']); + + $this->assertEquals('8080', $config->getDefaultHttpPort()); + $this->assertEquals('8443', $config->getDefaultHttpsPort()); + $this->assertEquals('updated', $config->getOrganization()); + } + + public function testToArrayRoundTrip(): void + { + $config = new Config(); + $config->apply([ + 'defaultHttpPort' => '3000', + 'defaultHttpsPort' => '3443', + 'organization' => 'testorg', + 'image' => 'testimage', + 'noStart' => true, + 'isUpgrade' => true, + 'isLocal' => true, + 'hostPath' => '/home/user', + 'lockedDatabase' => 'mongodb', + 'vars' => [['name' => 'KEY', 'default' => 'value']], + ]); + + $array = $config->toArray(); + + $this->assertEquals('3000', $array['defaultHttpPort']); + $this->assertEquals('3443', $array['defaultHttpsPort']); + $this->assertEquals('testorg', $array['organization']); + $this->assertEquals('testimage', $array['image']); + $this->assertTrue($array['noStart']); + $this->assertTrue($array['isUpgrade']); + $this->assertTrue($array['isLocal']); + $this->assertEquals('/home/user', $array['hostPath']); + $this->assertEquals('mongodb', $array['lockedDatabase']); + $this->assertCount(1, $array['vars']); + } + + public function testToArrayCanRecreateConfig(): void + { + $original = new Config([ + 'defaultHttpPort' => '5000', + 'isLocal' => true, + 'lockedDatabase' => 'mariadb', + ]); + + $rebuilt = new Config($original->toArray()); + + $this->assertEquals($original->getDefaultHttpPort(), $rebuilt->getDefaultHttpPort()); + $this->assertEquals($original->isLocal(), $rebuilt->isLocal()); + $this->assertEquals($original->getLockedDatabase(), $rebuilt->getLockedDatabase()); + $this->assertEquals($original->toArray(), $rebuilt->toArray()); + } + + public function testSetAndGetDefaultHttpPort(): void + { + $config = new Config(); + $config->setDefaultHttpPort('9090'); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testSetAndGetDefaultHttpsPort(): void + { + $config = new Config(); + $config->setDefaultHttpsPort('9443'); + $this->assertEquals('9443', $config->getDefaultHttpsPort()); + } + + public function testSetAndGetOrganization(): void + { + $config = new Config(); + $config->setOrganization('myorg'); + $this->assertEquals('myorg', $config->getOrganization()); + } + + public function testSetAndGetImage(): void + { + $config = new Config(); + $config->setImage('myimage'); + $this->assertEquals('myimage', $config->getImage()); + } + + public function testSetAndGetNoStart(): void + { + $config = new Config(); + $config->setNoStart(true); + $this->assertTrue($config->getNoStart()); + $config->setNoStart(false); + $this->assertFalse($config->getNoStart()); + } + + public function testSetAndGetIsUpgrade(): void + { + $config = new Config(); + $config->setIsUpgrade(true); + $this->assertTrue($config->isUpgrade()); + $config->setIsUpgrade(false); + $this->assertFalse($config->isUpgrade()); + } + + public function testSetAndGetIsLocal(): void + { + $config = new Config(); + $config->setIsLocal(true); + $this->assertTrue($config->isLocal()); + $config->setIsLocal(false); + $this->assertFalse($config->isLocal()); + } + + public function testSetAndGetHostPath(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + $this->assertEquals('/some/path', $config->getHostPath()); + $config->setHostPath(null); + $this->assertNull($config->getHostPath()); + } + + public function testSetAndGetLockedDatabase(): void + { + $config = new Config(); + $config->setLockedDatabase('mariadb'); + $this->assertEquals('mariadb', $config->getLockedDatabase()); + $config->setLockedDatabase(null); + $this->assertNull($config->getLockedDatabase()); + } + + public function testSetAndGetVars(): void + { + $config = new Config(); + $vars = [ + ['name' => '_APP_ENV', 'default' => 'production'], + ['name' => '_APP_DOMAIN', 'default' => 'localhost'], + ]; + $config->setVars($vars); + $this->assertEquals($vars, $config->getVars()); + } + + public function testJsonRoundTrip(): void + { + $config = new Config([ + 'defaultHttpPort' => '5000', + 'isUpgrade' => true, + 'lockedDatabase' => 'mongodb', + ]); + + $json = json_encode($config->toArray(), JSON_UNESCAPED_SLASHES); + $this->assertIsString($json); + + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + + $rebuilt = new Config($decoded); + $this->assertEquals($config->toArray(), $rebuilt->toArray()); + } + + public function testConstructorWithEmptyArray(): void + { + $config = new Config([]); + // Empty array has no known keys, so it gets set as vars + // But empty vars is still empty + $this->assertEmpty($config->getVars()); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testConstructorWithMixedKnownAndUnknownKeys(): void + { + // If at least one known key is found, apply() is used (not setVars) + $config = new Config([ + 'defaultHttpPort' => '9090', + 'unknownKey' => 'someValue', + ]); + // Known key should be applied + $this->assertEquals('9090', $config->getDefaultHttpPort()); + // Unknown key should be silently ignored by apply() + // Vars should remain empty since containsKnownKeys returns true + $this->assertEmpty($config->getVars()); + } + + public function testApplyWithEmptyArray(): void + { + $config = new Config(['defaultHttpPort' => '1234']); + $config->apply([]); + // Should not change anything + $this->assertEquals('1234', $config->getDefaultHttpPort()); + } + + public function testApplyBooleanCastingNoStart(): void + { + $config = new Config(); + + // Truthy int + $config->apply(['noStart' => 1]); + $this->assertTrue($config->getNoStart()); + + // Falsy int + $config->apply(['noStart' => 0]); + $this->assertFalse($config->getNoStart()); + } + + public function testApplyBooleanCastingIsUpgrade(): void + { + $config = new Config(); + + $config->apply(['isUpgrade' => 1]); + $this->assertTrue($config->isUpgrade()); + + $config->apply(['isUpgrade' => 0]); + $this->assertFalse($config->isUpgrade()); + } + + public function testApplyBooleanCastingIsLocal(): void + { + $config = new Config(); + + $config->apply(['isLocal' => 'true']); // string "true" is truthy + $this->assertTrue($config->isLocal()); + + $config->apply(['isLocal' => '']); // empty string is falsy + // But wait: the code checks $values['isLocal'] !== null first + // '' is not null, so (bool)'' = false + $this->assertFalse($config->isLocal()); + } + + public function testApplyNoStartWithNullDoesNotChange(): void + { + $config = new Config(); + $config->setNoStart(true); + $config->apply(['noStart' => null]); + // null is excluded by the null check + $this->assertTrue($config->getNoStart()); + } + + public function testApplyVarsWithNonArrayIgnored(): void + { + $config = new Config(); + $config->setVars([['name' => 'KEY', 'default' => 'val']]); + + $config->apply(['vars' => 'not an array']); + // Should not overwrite + $this->assertCount(1, $config->getVars()); + } + + public function testApplyVarsWithNullIgnored(): void + { + $config = new Config(); + $config->setVars([['name' => 'KEY', 'default' => 'val']]); + + $config->apply(['vars' => null]); + // is_array(null) = false, so should not overwrite + $this->assertCount(1, $config->getVars()); + } + + public function testApplyHostPathEmptyStringBecomesNull(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + + $config->apply(['hostPath' => '']); + // Empty string is handled: !== null && !== '' is false, so sets null + $this->assertNull($config->getHostPath()); + } + + public function testApplyLockedDatabaseIgnoresEmpty(): void + { + $config = new Config(); + $config->setLockedDatabase('mariadb'); + + $config->apply(['lockedDatabase' => '']); + // hasValidStringValue returns false for empty string + $this->assertEquals('mariadb', $config->getLockedDatabase()); + } + + public function testApplyLockedDatabaseIgnoresNull(): void + { + $config = new Config(); + $config->setLockedDatabase('mongodb'); + + $config->apply(['lockedDatabase' => null]); + // hasValidStringValue returns false for null + $this->assertEquals('mongodb', $config->getLockedDatabase()); + } + + public function testApplyPortWithIntegerValue(): void + { + $config = new Config(); + $config->apply(['defaultHttpPort' => 3000]); + // (string)3000 = '3000', not empty, so it should be applied + $this->assertEquals('3000', $config->getDefaultHttpPort()); + } + + public function testToArrayContainsAllExpectedKeys(): void + { + $config = new Config(); + $array = $config->toArray(); + + $expectedKeys = [ + 'defaultHttpPort', + 'defaultHttpsPort', + 'organization', + 'image', + 'noStart', + 'vars', + 'isUpgrade', + 'isLocal', + 'hostPath', + 'lockedDatabase', + ]; + + foreach ($expectedKeys as $key) { + $this->assertArrayHasKey($key, $array, "Missing key: $key"); + } + $this->assertCount(count($expectedKeys), $array); + } + + public function testToArrayDefaultsMatchConstructorDefaults(): void + { + $config = new Config(); + $array = $config->toArray(); + + $this->assertEquals('80', $array['defaultHttpPort']); + $this->assertEquals('443', $array['defaultHttpsPort']); + $this->assertEquals('appwrite', $array['organization']); + $this->assertEquals('appwrite', $array['image']); + $this->assertFalse($array['noStart']); + $this->assertEmpty($array['vars']); + $this->assertFalse($array['isUpgrade']); + $this->assertFalse($array['isLocal']); + $this->assertNull($array['hostPath']); + $this->assertNull($array['lockedDatabase']); + } + + public function testMultipleApplyCallsAccumulate(): void + { + $config = new Config(); + + $config->apply(['defaultHttpPort' => '1111']); + $config->apply(['defaultHttpsPort' => '2222']); + $config->apply(['organization' => 'org']); + $config->apply(['isLocal' => true]); + + $this->assertEquals('1111', $config->getDefaultHttpPort()); + $this->assertEquals('2222', $config->getDefaultHttpsPort()); + $this->assertEquals('org', $config->getOrganization()); + $this->assertTrue($config->isLocal()); + } + + public function testApplyOverwritesPreviousValues(): void + { + $config = new Config(['defaultHttpPort' => '1111']); + $this->assertEquals('1111', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => '2222']); + $this->assertEquals('2222', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => '3333']); + $this->assertEquals('3333', $config->getDefaultHttpPort()); + } + + public function testSetVarsReplacesNotMerges(): void + { + $config = new Config(); + $config->setVars([['name' => 'A', 'default' => '1']]); + $config->setVars([['name' => 'B', 'default' => '2']]); + + $vars = $config->getVars(); + $this->assertCount(1, $vars); + $this->assertEquals('B', $vars[0]['name']); + } + + public function testApplyVarsReplacesNotMerges(): void + { + $config = new Config(); + $config->apply(['vars' => [['name' => 'A', 'default' => '1']]]); + $config->apply(['vars' => [['name' => 'B', 'default' => '2']]]); + + $vars = $config->getVars(); + $this->assertCount(1, $vars); + $this->assertEquals('B', $vars[0]['name']); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php new file mode 100644 index 0000000000..6c36e6d732 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -0,0 +1,1160 @@ +tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + + $root = dirname(__DIR__, 6); + $this->state = new State([ + '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', + ]); + + // Preserve env state + $env = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->savedEnv = $env !== false ? $env : null; + } + + protected function tearDown(): void + { + // Clean up temp files + $files = glob($this->tempDir . '/*'); + if (is_array($files)) { + foreach ($files as $file) { + @unlink($file); + } + } + @rmdir($this->tempDir); + + // Clean up progress files + foreach ($this->progressFiles as $file) { + @unlink($file); + } + + // Clean up lock file + @unlink(Server::INSTALLER_LOCK_FILE); + @unlink(Server::INSTALLER_CONFIG_FILE); + + // Restore env state + if ($this->savedEnv !== null) { + putenv('APPWRITE_INSTALLER_CONFIG=' . $this->savedEnv); + } else { + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + $this->state = null; + } + + private function trackProgressFile(string $installId): void + { + $this->progressFiles[] = $this->state->progressFilePath($installId); + } + + public function testSanitizeInstallIdWithValidId(): void + { + $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc123')); + } + + public function testSanitizeInstallIdWithSpecialChars(): void + { + $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc!@#123')); + } + + public function testSanitizeInstallIdWithHyphensAndUnderscores(): void + { + $this->assertEquals('abc-123_def', $this->state->sanitizeInstallId('abc-123_def')); + } + + public function testSanitizeInstallIdTruncatesTo64Chars(): void + { + $long = str_repeat('a', 100); + $this->assertEquals(64, strlen($this->state->sanitizeInstallId($long))); + } + + public function testSanitizeInstallIdWithEmptyString(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId('')); + } + + public function testSanitizeInstallIdWithNonString(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId(123)); + $this->assertEquals('', $this->state->sanitizeInstallId(null)); + } + + public function testHashSensitiveValueProducesConsistentHash(): void + { + $hash1 = $this->state->hashSensitiveValue('secret'); + $hash2 = $this->state->hashSensitiveValue('secret'); + $this->assertEquals($hash1, $hash2); + } + + public function testHashSensitiveValueDifferentInputsDifferentHashes(): void + { + $hash1 = $this->state->hashSensitiveValue('secret1'); + $hash2 = $this->state->hashSensitiveValue('secret2'); + $this->assertNotEquals($hash1, $hash2); + } + + public function testHashSensitiveValueTrimsWhitespace(): void + { + $hash1 = $this->state->hashSensitiveValue('secret'); + $hash2 = $this->state->hashSensitiveValue(' secret '); + $this->assertEquals($hash1, $hash2); + } + + public function testHashSensitiveValueEmptyStringReturnsEmpty(): void + { + $this->assertEquals('', $this->state->hashSensitiveValue('')); + $this->assertEquals('', $this->state->hashSensitiveValue(' ')); + } + + public function testHashSensitiveValueReturnsSha256(): void + { + $hash = $this->state->hashSensitiveValue('test'); + $this->assertEquals(64, strlen($hash)); // SHA-256 produces 64 hex chars + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash); + } + + public function testIsValidPortWithValidPorts(): void + { + $this->assertTrue($this->state->isValidPort('1')); + $this->assertTrue($this->state->isValidPort('80')); + $this->assertTrue($this->state->isValidPort('443')); + $this->assertTrue($this->state->isValidPort('8080')); + $this->assertTrue($this->state->isValidPort('65535')); + } + + public function testIsValidPortWithInvalidPorts(): void + { + $this->assertFalse($this->state->isValidPort('0')); + $this->assertFalse($this->state->isValidPort('65536')); + $this->assertFalse($this->state->isValidPort('-1')); + $this->assertFalse($this->state->isValidPort('abc')); + $this->assertFalse($this->state->isValidPort('')); + $this->assertFalse($this->state->isValidPort('80.5')); + $this->assertFalse($this->state->isValidPort('80abc')); + } + + public function testIsValidPortWithIntegerInput(): void + { + $this->assertTrue($this->state->isValidPort(80)); + $this->assertTrue($this->state->isValidPort(443)); + $this->assertFalse($this->state->isValidPort(0)); + } + + public function testIsValidEmailAddressWithValidEmails(): void + { + $this->assertTrue($this->state->isValidEmailAddress('user@example.com')); + $this->assertTrue($this->state->isValidEmailAddress('test.user@domain.org')); + $this->assertTrue($this->state->isValidEmailAddress('admin+tag@example.co.uk')); + } + + public function testIsValidEmailAddressWithInvalidEmails(): void + { + $this->assertFalse($this->state->isValidEmailAddress('')); + $this->assertFalse($this->state->isValidEmailAddress('notanemail')); + $this->assertFalse($this->state->isValidEmailAddress('@domain.com')); + $this->assertFalse($this->state->isValidEmailAddress('user@')); + } + + public function testIsValidPasswordWithValidPasswords(): void + { + $this->assertTrue($this->state->isValidPassword('12345678')); + $this->assertTrue($this->state->isValidPassword('abcdefgh')); + $this->assertTrue($this->state->isValidPassword('P@ssw0rd!')); + } + + public function testIsValidPasswordWithInvalidPasswords(): void + { + $this->assertFalse($this->state->isValidPassword('')); + $this->assertFalse($this->state->isValidPassword('short')); + $this->assertFalse($this->state->isValidPassword('1234567')); // 7 chars + $this->assertFalse($this->state->isValidPassword(' ')); // 8 spaces, no non-whitespace + } + + public function testIsValidSecretKeyWithValidKeys(): void + { + $this->assertTrue($this->state->isValidSecretKey('a')); + $this->assertTrue($this->state->isValidSecretKey('my-secret-key')); + $this->assertTrue($this->state->isValidSecretKey(str_repeat('x', 64))); + } + + public function testIsValidSecretKeyWithInvalidKeys(): void + { + $this->assertFalse($this->state->isValidSecretKey('')); + $this->assertFalse($this->state->isValidSecretKey(str_repeat('x', 65))); + } + + public function testIsValidAccountNameWithValidNames(): void + { + $this->assertTrue($this->state->isValidAccountName('John')); + $this->assertTrue($this->state->isValidAccountName('a')); + } + + public function testIsValidAccountNameWithInvalidNames(): void + { + $this->assertFalse($this->state->isValidAccountName('')); + $this->assertFalse($this->state->isValidAccountName(' ')); + } + + public function testIsValidAppDomainInputWithValidDomains(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('localhost')); + $this->assertTrue($this->state->isValidAppDomainInput('example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('sub.example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1')); + $this->assertTrue($this->state->isValidAppDomainInput('192.168.1.1')); + } + + public function testIsValidAppDomainInputWithPort(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('localhost:8080')); + $this->assertTrue($this->state->isValidAppDomainInput('example.com:443')); + $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1:3000')); + } + + public function testIsValidAppDomainInputWithIpv6(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('[::1]')); + $this->assertTrue($this->state->isValidAppDomainInput('[::1]:8080')); + } + + public function testIsValidAppDomainInputWithInvalidDomains(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('')); + $this->assertFalse($this->state->isValidAppDomainInput(' ')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:99999')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:0')); + $this->assertFalse($this->state->isValidAppDomainInput('host:port:extra')); + } + + public function testIsValidDatabaseAdapterWithValidAdapters(): void + { + $this->assertTrue($this->state->isValidDatabaseAdapter('mongodb')); + $this->assertTrue($this->state->isValidDatabaseAdapter('mariadb')); + $this->assertTrue($this->state->isValidDatabaseAdapter('postgresql')); + } + + public function testIsValidDatabaseAdapterWithInvalidAdapters(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter('')); + $this->assertFalse($this->state->isValidDatabaseAdapter('mysql')); + $this->assertFalse($this->state->isValidDatabaseAdapter('postgres')); + $this->assertFalse($this->state->isValidDatabaseAdapter('PostgreSQL')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); // case sensitive + } + + public function testProgressFilePathFormat(): void + { + $path = $this->state->progressFilePath('test123'); + $this->assertStringContainsString('appwrite-install-test123.json', $path); + $this->assertStringStartsWith(sys_get_temp_dir(), $path); + } + + public function testReadProgressFileReturnsDefaultForMissing(): void + { + $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertArrayHasKey('steps', $data); + $this->assertEmpty($data['steps']); + } + + public function testWriteAndReadProgressFile(): void + { + $installId = 'test-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Writing environment variables', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('steps', $data); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); + $this->assertEquals('Writing environment variables', $data['steps'][Server::STEP_ENV_VARS]['message']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileAccumulatesSteps(): void + { + $installId = 'test-multi-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Generating compose file', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertCount(2, $data['steps']); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['steps']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresPayload(): void + { + $installId = 'test-payload-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => '80', + 'httpsPort' => '443', + 'database' => 'mariadb', + ], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Started', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('payload', $data); + $this->assertEquals('80', $data['payload']['httpPort']); + $this->assertEquals('443', $data['payload']['httpsPort']); + $this->assertEquals('mariadb', $data['payload']['database']); + $this->assertArrayHasKey('startedAt', $data); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresErrorMessage(): void + { + $installId = 'test-error-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_CONTAINERS, + 'status' => Server::STATUS_ERROR, + 'message' => 'Container failed to start', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('error', $data); + $this->assertEquals('Container failed to start', $data['error']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresDetails(): void + { + $installId = 'test-details-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done', + 'details' => ['composeFile' => '/path/to/docker-compose.yml'], + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('details', $data); + $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['details']); + $this->assertEquals('/path/to/docker-compose.yml', $data['details'][Server::STEP_DOCKER_COMPOSE]['composeFile']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testBuildConfigReturnsConfigInstance(): void + { + // Clear env to avoid interference + putenv('APPWRITE_INSTALLER_CONFIG'); + + $config = $this->state->buildConfig([], false); + $this->assertInstanceOf(Config::class, $config); + } + + public function testBuildConfigAppliesOverrides(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + $config = $this->state->buildConfig(['defaultHttpPort' => '9090'], false); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testBuildConfigFromEnvVar(): void + { + $envData = json_encode([ + 'defaultHttpPort' => '8888', + 'isUpgrade' => true, + ]); + putenv('APPWRITE_INSTALLER_CONFIG=' . $envData); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('8888', $config->getDefaultHttpPort()); + $this->assertTrue($config->isUpgrade()); + + // Cleanup + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + public function testBuildConfigOverridesEnv(): void + { + $envData = json_encode(['defaultHttpPort' => '8888']); + putenv('APPWRITE_INSTALLER_CONFIG=' . $envData); + + $config = $this->state->buildConfig(['defaultHttpPort' => '7777'], true); + $this->assertEquals('7777', $config->getDefaultHttpPort()); + + // Cleanup + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + public function testSanitizeInstallIdWithOnlySpecialChars(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId('!@#$%^&*()')); + } + + public function testSanitizeInstallIdWithUnicode(): void + { + // Unicode letters are stripped byte-by-byte, only ASCII alphanum + hyphen + underscore kept + // 'é' is 2 bytes (0xC3 0xA9), both stripped => 'héllo' becomes 'hllo' + $this->assertEquals('hllo', $this->state->sanitizeInstallId('héllo')); + } + + public function testSanitizeInstallIdWithExactly64Chars(): void + { + $exact = str_repeat('b', 64); + $this->assertEquals($exact, $this->state->sanitizeInstallId($exact)); + $this->assertEquals(64, strlen($this->state->sanitizeInstallId($exact))); + } + + public function testSanitizeInstallIdWithBooleanInput(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId(true)); + $this->assertEquals('', $this->state->sanitizeInstallId(false)); + } + + public function testSanitizeInstallIdWithArrayInput(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId([])); + } + + public function testSanitizeInstallIdPreservesCase(): void + { + $this->assertEquals('AbCdEf', $this->state->sanitizeInstallId('AbCdEf')); + } + + public function testIsValidPortBoundaryValues(): void + { + $this->assertTrue($this->state->isValidPort('1')); + $this->assertTrue($this->state->isValidPort('65535')); + $this->assertFalse($this->state->isValidPort('0')); + $this->assertFalse($this->state->isValidPort('65536')); + } + + public function testIsValidPortWithLeadingZeros(): void + { + // '080' is digits-only and parses to 80 which is in range + $this->assertTrue($this->state->isValidPort('080')); + // '00' parses to 0, which is out of range + $this->assertFalse($this->state->isValidPort('00')); + } + + public function testIsValidPortWithWhitespace(): void + { + // Contains non-digit characters + $this->assertFalse($this->state->isValidPort(' 80')); + $this->assertFalse($this->state->isValidPort('80 ')); + $this->assertFalse($this->state->isValidPort(' 80 ')); + } + + public function testIsValidPortWithNegativeNumber(): void + { + $this->assertFalse($this->state->isValidPort('-80')); + $this->assertFalse($this->state->isValidPort('-1')); + } + + public function testIsValidPortWithVeryLargeNumber(): void + { + $this->assertFalse($this->state->isValidPort('999999')); + $this->assertFalse($this->state->isValidPort('100000')); + } + + public function testIsValidPasswordExactly8Chars(): void + { + $this->assertTrue($this->state->isValidPassword('12345678')); + $this->assertFalse($this->state->isValidPassword('1234567')); + } + + public function testIsValidPasswordWithTabsAndNewlines(): void + { + // Tabs/newlines count as whitespace, but need at least one non-whitespace + $this->assertFalse($this->state->isValidPassword("\t\t\t\t\t\t\t\t")); // 8 tabs + $this->assertTrue($this->state->isValidPassword("\t\t\t\ttest")); // mixed + } + + public function testIsValidPasswordWithMixedWhitespaceAndChars(): void + { + $this->assertTrue($this->state->isValidPassword(' a ')); // has non-whitespace + } + + public function testIsValidSecretKeyExactly64Chars(): void + { + $this->assertTrue($this->state->isValidSecretKey(str_repeat('a', 64))); + } + + public function testIsValidSecretKeyWithWhitespace(): void + { + // Whitespace-only is still non-empty and <= 64 chars + $this->assertTrue($this->state->isValidSecretKey(' ')); + $this->assertTrue($this->state->isValidSecretKey(' ')); + } + + public function testIsValidAppDomainInputWithEmptyPort(): void + { + // "host:" splits to ['host', ''] - empty port with null check + $this->assertTrue($this->state->isValidAppDomainInput('localhost:')); + } + + public function testIsValidAppDomainInputWithIpv4Address(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('10.0.0.1')); + $this->assertTrue($this->state->isValidAppDomainInput('255.255.255.255')); + $this->assertTrue($this->state->isValidAppDomainInput('0.0.0.0')); + } + + public function testIsValidAppDomainInputIpv6WithoutBrackets(): void + { + // Raw IPv6 without brackets: "::1" has two colons, so count($parts) > 2 => false + $this->assertFalse($this->state->isValidAppDomainInput('::1')); + $this->assertFalse($this->state->isValidAppDomainInput('fe80::1')); + } + + public function testIsValidAppDomainInputIpv6MalformedBrackets(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('[')); + $this->assertFalse($this->state->isValidAppDomainInput('[]')); + $this->assertFalse($this->state->isValidAppDomainInput('[invalid')); + } + + public function testIsValidAppDomainInputWithSubdomains(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('a.b.c.d.example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('my-app.example.io:8080')); + } + + public function testIsValidAppDomainInputWithInvalidPortNumber(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('localhost:abc')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:70000')); + $this->assertFalse($this->state->isValidAppDomainInput('[::1]:70000')); + } + + public function testIsValidDatabaseAdapterWithWhitespace(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter(' mongodb')); + $this->assertFalse($this->state->isValidDatabaseAdapter('mariadb ')); + $this->assertFalse($this->state->isValidDatabaseAdapter(' postgresql')); + } + + public function testIsValidDatabaseAdapterCaseSensitivity(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MariaDB')); + $this->assertFalse($this->state->isValidDatabaseAdapter('PostgreSQL')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MONGODB')); + } + + public function testReadProgressFileWithCorruptedJson(): void + { + $installId = 'test-corrupt-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, 'not valid json {{{'); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertArrayHasKey('steps', $data); + $this->assertEmpty($data['steps']); + } + + public function testReadProgressFileWithEmptyFile(): void + { + $installId = 'test-empty-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, ''); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertEmpty($data['steps']); + } + + public function testReadProgressFileWithJsonScalar(): void + { + $installId = 'test-scalar-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, '"just a string"'); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertEmpty($data['steps']); + } + + public function testWriteProgressFileOverwritesExistingStep(): void + { + $installId = 'test-overwrite-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Working...', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done!', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertCount(1, $data['steps']); // Still 1 step, overwritten + $this->assertEquals(Server::STATUS_COMPLETED, $data['steps'][Server::STEP_ENV_VARS]['status']); + $this->assertEquals('Done!', $data['steps'][Server::STEP_ENV_VARS]['message']); + } + + public function testWriteProgressFileWithEmptyStep(): void + { + $installId = 'test-emptystep-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => '', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'No step name', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // Empty step name treated as falsy, should not add to steps + $this->assertEmpty($data['steps']); + } + + public function testWriteProgressFilePreservesPayloadAcrossWrites(): void + { + $installId = 'test-persist-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80', 'database' => 'mongodb'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Starting', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Env done', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // Payload from first write should still be present + $this->assertArrayHasKey('payload', $data); + $this->assertEquals('80', $data['payload']['httpPort']); + $this->assertEquals('mongodb', $data['payload']['database']); + // Both steps should exist + $this->assertArrayHasKey('start', $data['steps']); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + } + + public function testWriteProgressFileUpdatesTimestamp(): void + { + $installId = 'test-time-' . uniqid(); + $this->trackProgressFile($installId); + $now = time(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'test', + 'updatedAt' => $now, + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertEquals($now, $data['updatedAt']); + } + + public function testWriteProgressFileStartedAtOnlySetOnce(): void + { + $installId = 'test-startedat-' . uniqid(); + $this->trackProgressFile($installId); + $firstTime = time() - 100; + + // First write with payload sets startedAt + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Starting', + 'updatedAt' => $firstTime, + ]); + + $data = $this->state->readProgressFile($installId); + $startedAt = $data['startedAt']; + + // Second write with payload should NOT overwrite startedAt + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80'], + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Env', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertEquals($startedAt, $data['startedAt']); + } + + public function testReserveGlobalLockFirstLockSucceeds(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-test-' . uniqid(); + $result = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockSameIdCanRelock(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-relock-' . uniqid(); + + $result1 = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result1); + + // Same ID can re-reserve + $result2 = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result2); + } + + public function testReserveGlobalLockDifferentIdBlocked(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-id1-' . uniqid(); + $installId2 = 'lock-id2-' . uniqid(); + + $result1 = $this->state->reserveGlobalLock($installId1); + $this->assertEquals('ok', $result1); + + // Different ID should be blocked + $result2 = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('locked', $result2); + } + + public function testReserveGlobalLockAfterCompleted(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-done-' . uniqid(); + $installId2 = 'lock-new-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + $this->state->updateGlobalLock($installId1, Server::STATUS_COMPLETED); + + // After completion, a new install should be able to lock + $result = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockAfterError(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-err-' . uniqid(); + $installId2 = 'lock-retry-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + $this->state->updateGlobalLock($installId1, Server::STATUS_ERROR); + + // After error, a new install should be able to lock + $result = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockExpiredLockAllowsNew(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + + // Manually write an expired lock (updatedAt way in the past) + $expiredLock = [ + 'installId' => 'expired-lock', + 'status' => Server::STATUS_IN_PROGRESS, + 'updatedAt' => time() - 7200, // 2 hours ago, timeout is 1 hour + ]; + file_put_contents(Server::INSTALLER_LOCK_FILE, json_encode($expiredLock)); + + $newId = 'lock-after-expired-' . uniqid(); + $result = $this->state->reserveGlobalLock($newId); + $this->assertEquals('ok', $result); + } + + public function testUpdateGlobalLockUpdatesOwnLock(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-update-' . uniqid(); + + $this->state->reserveGlobalLock($installId); + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + + // Read lock file directly to verify + $contents = file_get_contents(Server::INSTALLER_LOCK_FILE); + $this->assertNotFalse($contents); + $lock = json_decode($contents, true); + $this->assertIsArray($lock); + $this->assertEquals($installId, $lock['installId']); + $this->assertEquals(Server::STATUS_COMPLETED, $lock['status']); + } + + public function testUpdateGlobalLockIgnoresDifferentId(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-owner-' . uniqid(); + $installId2 = 'lock-intruder-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + + // Attempt to update with a different ID should be silently ignored + $this->state->updateGlobalLock($installId2, Server::STATUS_COMPLETED); + + // Original lock should still be in progress + $contents = file_get_contents(Server::INSTALLER_LOCK_FILE); + $lock = json_decode($contents, true); + $this->assertEquals($installId1, $lock['installId']); + $this->assertEquals(Server::STATUS_IN_PROGRESS, $lock['status']); + } + + public function testApplyEnvConfigWithConfigObject(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $cfg = new Config(['defaultHttpPort' => '5555', 'isLocal' => true]); + $this->state->applyEnvConfig($cfg); + + // Verify env var was set + $envVal = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->assertNotFalse($envVal); + + $decoded = json_decode($envVal, true); + $this->assertIsArray($decoded); + $this->assertEquals('5555', $decoded['defaultHttpPort']); + $this->assertTrue($decoded['isLocal']); + + // Verify config file was written + $this->assertFileExists(Server::INSTALLER_CONFIG_FILE); + $fileContents = file_get_contents(Server::INSTALLER_CONFIG_FILE); + $this->assertNotFalse($fileContents); + $fileDecoded = json_decode($fileContents, true); + $this->assertEquals('5555', $fileDecoded['defaultHttpPort']); + } + + public function testApplyEnvConfigWithArray(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $this->state->applyEnvConfig(['defaultHttpPort' => '6666']); + + $envVal = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->assertNotFalse($envVal); + $decoded = json_decode($envVal, true); + $this->assertEquals('6666', $decoded['defaultHttpPort']); + } + + public function testApplyEnvConfigThenBuildConfigReadsIt(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $cfg = new Config(['defaultHttpPort' => '4444', 'isUpgrade' => true]); + $this->state->applyEnvConfig($cfg); + + // buildConfig with useEnv=true should pick up the env var + $rebuilt = $this->state->buildConfig([], true); + $this->assertEquals('4444', $rebuilt->getDefaultHttpPort()); + $this->assertTrue($rebuilt->isUpgrade()); + } + + public function testBuildConfigWithInvalidEnvJson(): void + { + putenv('APPWRITE_INSTALLER_CONFIG=not-valid-json'); + + // Should fall back to config file (or defaults if file doesn't exist) + @unlink(Server::INSTALLER_CONFIG_FILE); + $config = $this->state->buildConfig([], true); + // Should get defaults since both env and file are invalid/missing + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithEmptyEnvVar(): void + { + putenv('APPWRITE_INSTALLER_CONFIG='); + + @unlink(Server::INSTALLER_CONFIG_FILE); + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigFallsBackToConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + // Write a config file + $data = json_encode(['defaultHttpPort' => '3333']); + file_put_contents(Server::INSTALLER_CONFIG_FILE, $data); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('3333', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithCorruptedConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + file_put_contents(Server::INSTALLER_CONFIG_FILE, 'garbage data {{{'); + + $config = $this->state->buildConfig([], true); + // Should get defaults + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithEmptyConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + file_put_contents(Server::INSTALLER_CONFIG_FILE, ''); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigUseEnvFalseIgnoresEnvAndFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG=' . json_encode(['defaultHttpPort' => '9999'])); + file_put_contents(Server::INSTALLER_CONFIG_FILE, json_encode(['defaultHttpPort' => '8888'])); + + $config = $this->state->buildConfig([], false); + // Neither env nor file should be used + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithJsonScalarEnvVar(): void + { + // A JSON scalar (string) is not an array, so decoding succeeds but is_array fails + putenv('APPWRITE_INSTALLER_CONFIG="just a string"'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testHashSensitiveValueWithNewlines(): void + { + // Newlines are not stripped by trim but surrounding whitespace is + $hash1 = $this->state->hashSensitiveValue("line1\nline2"); + $hash2 = $this->state->hashSensitiveValue("line1\nline2"); + $this->assertEquals($hash1, $hash2); + $this->assertNotEmpty($hash1); + } + + public function testHashSensitiveValueWithOnlyNewline(): void + { + // A newline is not whitespace that trim() removes? Actually trim() removes \n + // "\n" trimmed becomes "" => should return '' + $this->assertEquals('', $this->state->hashSensitiveValue("\n")); + } + + public function testIsValidEmailAddressWithUnicodeLocal(): void + { + // PHP's FILTER_VALIDATE_EMAIL does not support internationalized emails + $this->assertFalse($this->state->isValidEmailAddress('ünïcödé@example.com')); + } + + public function testIsValidEmailAddressWithDoubleAt(): void + { + $this->assertFalse($this->state->isValidEmailAddress('user@@example.com')); + } + + public function testIsValidEmailAddressWithSpaces(): void + { + $this->assertFalse($this->state->isValidEmailAddress('user @example.com')); + $this->assertFalse($this->state->isValidEmailAddress('user@ example.com')); + } + + public function testIsValidAccountNameWithOnlyTabs(): void + { + $this->assertFalse($this->state->isValidAccountName("\t\t")); + } + + public function testIsValidAccountNameWithMixedWhitespace(): void + { + $this->assertTrue($this->state->isValidAccountName(" a ")); + } + + public function testProgressFilePathWithSpecialCharsInId(): void + { + // The ID would normally be sanitized before this call, but the method itself + // just concatenates + $path = $this->state->progressFilePath('test-with-special'); + $this->assertStringContainsString('appwrite-install-test-with-special.json', $path); + } + + public function testProgressFilePathWithEmptyId(): void + { + $path = $this->state->progressFilePath(''); + $this->assertStringContainsString('appwrite-install-.json', $path); + } + + public function testWriteProgressFileCompletedDoesNotSetError(): void + { + $installId = 'test-noerror-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'All good', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayNotHasKey('error', $data); + } + + public function testWriteProgressFileInProgressDoesNotSetError(): void + { + $installId = 'test-noerrip-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Working', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayNotHasKey('error', $data); + } + + public function testWriteProgressFileWithNoStep(): void + { + $installId = 'test-nostep-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'No step provided', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // No step key means no step should be recorded + $this->assertEmpty($data['steps']); + // But updatedAt should still be set + $this->assertArrayHasKey('updatedAt', $data); + } + + public function testFullInstallationLifecycle(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lifecycle-' . uniqid(); + $this->trackProgressFile($installId); + + // 1. Reserve lock + $lockResult = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $lockResult); + + // 2. Write progress through multiple steps + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80', 'database' => 'mongodb'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Started', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Env vars written', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Compose generated', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_CONTAINERS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Containers started', + 'updatedAt' => time(), + ]); + + // 3. Verify progress + $data = $this->state->readProgressFile($installId); + $this->assertCount(4, $data['steps']); // start + 3 steps + $this->assertArrayHasKey('payload', $data); + $this->assertArrayHasKey('startedAt', $data); + + // 4. Complete the lock + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + + // 5. Verify a new install can now proceed + $newId = 'lifecycle-new-' . uniqid(); + $this->trackProgressFile($newId); + $newResult = $this->state->reserveGlobalLock($newId); + $this->assertEquals('ok', $newResult); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php new file mode 100644 index 0000000000..c453dcade4 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php @@ -0,0 +1,168 @@ +validator = new AppDomain(); + } + + public function tearDown(): void + { + $this->validator = null; + } + + public function testDescription(): void + { + $this->assertNotEmpty($this->validator->getDescription()); + $this->assertIsString($this->validator->getDescription()); + } + + public function testIsArray(): void + { + $this->assertFalse($this->validator->isArray()); + } + + public function testType(): void + { + $this->assertEquals($this->validator::TYPE_STRING, $this->validator->getType()); + } + + public function testRejectsNonStringTypes(): void + { + $this->assertFalse($this->validator->isValid(null)); + $this->assertFalse($this->validator->isValid(false)); + $this->assertFalse($this->validator->isValid(true)); + $this->assertFalse($this->validator->isValid(123)); + $this->assertFalse($this->validator->isValid(12.34)); + $this->assertFalse($this->validator->isValid([])); + $this->assertFalse($this->validator->isValid(new \stdClass())); + } + + public function testRejectsEmptyString(): void + { + $this->assertFalse($this->validator->isValid('')); + } + + public function testRejectsWhitespaceOnly(): void + { + $this->assertFalse($this->validator->isValid(' ')); + $this->assertFalse($this->validator->isValid("\t")); + $this->assertFalse($this->validator->isValid("\n")); + } + + public function testAcceptsLocalhost(): void + { + $this->assertTrue($this->validator->isValid('localhost')); + } + + public function testAcceptsLocalhostWithPort(): void + { + $this->assertTrue($this->validator->isValid('localhost:8080')); + $this->assertTrue($this->validator->isValid('localhost:80')); + $this->assertTrue($this->validator->isValid('localhost:443')); + $this->assertTrue($this->validator->isValid('localhost:1')); + $this->assertTrue($this->validator->isValid('localhost:65535')); + } + + public function testAcceptsValidDomains(): void + { + $this->assertTrue($this->validator->isValid('example.com')); + $this->assertTrue($this->validator->isValid('sub.example.com')); + $this->assertTrue($this->validator->isValid('deep.sub.example.com')); + $this->assertTrue($this->validator->isValid('appwrite.io')); + $this->assertTrue($this->validator->isValid('my-app.example.org')); + } + + public function testAcceptsDomainsWithPort(): void + { + $this->assertTrue($this->validator->isValid('example.com:443')); + $this->assertTrue($this->validator->isValid('example.com:8080')); + $this->assertTrue($this->validator->isValid('sub.example.com:3000')); + } + + public function testAcceptsIPv4Addresses(): void + { + $this->assertTrue($this->validator->isValid('127.0.0.1')); + $this->assertTrue($this->validator->isValid('192.168.1.1')); + $this->assertTrue($this->validator->isValid('10.0.0.1')); + $this->assertTrue($this->validator->isValid('0.0.0.0')); + $this->assertTrue($this->validator->isValid('255.255.255.255')); + } + + public function testAcceptsIPv4WithPort(): void + { + $this->assertTrue($this->validator->isValid('127.0.0.1:8080')); + $this->assertTrue($this->validator->isValid('192.168.1.1:443')); + $this->assertTrue($this->validator->isValid('10.0.0.1:3000')); + } + + public function testAcceptsIPv6BracketNotation(): void + { + $this->assertTrue($this->validator->isValid('[::1]')); + $this->assertTrue($this->validator->isValid('[::1]:8080')); + $this->assertTrue($this->validator->isValid('[2001:db8::1]')); + $this->assertTrue($this->validator->isValid('[2001:db8::1]:443')); + // Scoped IPv6 with zone ID is not supported by FILTER_VALIDATE_IP + $this->assertFalse($this->validator->isValid('[fe80::1%25eth0]')); + } + + public function testRejectsInvalidDomains(): void + { + $this->assertFalse($this->validator->isValid('-invalid.com')); + $this->assertFalse($this->validator->isValid('invalid-.com')); + $this->assertFalse($this->validator->isValid('.example.com')); + } + + public function testRejectsInvalidPorts(): void + { + $this->assertFalse($this->validator->isValid('localhost:0')); + $this->assertFalse($this->validator->isValid('localhost:65536')); + $this->assertFalse($this->validator->isValid('localhost:99999')); + $this->assertFalse($this->validator->isValid('localhost:abc')); + $this->assertFalse($this->validator->isValid('localhost:-1')); + } + + public function testRejectsMultipleColonsWithoutBrackets(): void + { + $this->assertFalse($this->validator->isValid('::1')); + $this->assertFalse($this->validator->isValid('2001:db8::1')); + $this->assertFalse($this->validator->isValid('a:b:c')); + } + + public function testRejectsMalformedIPv6Brackets(): void + { + $this->assertFalse($this->validator->isValid('[')); + $this->assertFalse($this->validator->isValid('[]')); + $this->assertFalse($this->validator->isValid('[::1')); + $this->assertFalse($this->validator->isValid('::1]')); + $this->assertFalse($this->validator->isValid('[invalid')); + } + + public function testPortBoundaryValues(): void + { + $this->assertTrue($this->validator->isValid('localhost:1')); + $this->assertTrue($this->validator->isValid('localhost:65535')); + $this->assertFalse($this->validator->isValid('localhost:0')); + $this->assertFalse($this->validator->isValid('localhost:65536')); + } + + public function testTrimsWhitespace(): void + { + $this->assertTrue($this->validator->isValid(' localhost ')); + $this->assertTrue($this->validator->isValid(' example.com ')); + } + + public function testAcceptsEmptyPortSegment(): void + { + // 'localhost:' splits into host='localhost', port='' — empty port is skipped + $this->assertTrue($this->validator->isValid('localhost:')); + } +}