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 619f700d91..9af261bb1a 100644 --- a/app/cli.php +++ b/app/cli.php @@ -157,7 +157,7 @@ $cli->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 @@ -178,7 +178,7 @@ $cli->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 @@ -201,9 +201,8 @@ $cli->setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati $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; } @@ -219,8 +218,8 @@ $cli->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/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 8556cbeb0f..9edbe3c92c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -593,7 +593,7 @@ $container->set('dbForProject', function (Group $pools, Database $dbForPlatform, if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -852,7 +852,7 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -882,9 +882,8 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $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; } @@ -900,8 +899,8 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization ->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 f05133c875..435b0aaa1d 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -116,7 +116,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 cab557a1e8..65a70f96ba 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +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" + "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", + "bench": "vendor/bin/phpbench run --report=benchmark", + "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": { @@ -90,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": "*", diff --git a/composer.lock b/composer.lock index 4d4f1015eb..fdde1cb0a3 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": "5848ab9fef2b8aefac2e80f54bd1f146", + "content-hash": "13579de3d747c541fdcce4f709df8e57", "packages": [ { "name": "adhocore/jwt", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "dev-main", + "version": "dev-fix-collection-recreate", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "bb89e8c4a5d534fc7e650c4438aa796667fe160a" + "reference": "5208630969dfdfbe8eda9c34c6b28ce711ece7c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/bb89e8c4a5d534fc7e650c4438aa796667fe160a", - "reference": "bb89e8c4a5d534fc7e650c4438aa796667fe160a", + "url": "https://api.github.com/repos/utopia-php/database/zipball/5208630969dfdfbe8eda9c34c6b28ce711ece7c4", + "reference": "5208630969dfdfbe8eda9c34c6b28ce711ece7c4", "shasum": "" }, "require": { @@ -3890,7 +3890,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" ], @@ -3903,10 +3934,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/main" + "source": "https://github.com/utopia-php/database/tree/fix-collection-recreate", + "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-03-16T11:41:45+00:00" + "time": "2026-03-16T11:58:09+00:00" }, { "name": "utopia-php/detector", @@ -5447,16 +5478,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.8", + "version": "1.11.6", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "bf45bb91419f157e6d539d05f3f2c2d2120c90dc" + "reference": "f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/bf45bb91419f157e6d539d05f3f2c2d2120c90dc", - "reference": "bf45bb91419f157e6d539d05f3f2c2d2120c90dc", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38", + "reference": "f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38", "shasum": "" }, "require": { @@ -5492,9 +5523,9 @@ "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.8" + "source": "https://github.com/appwrite/sdk-generator/tree/1.11.6" }, - "time": "2026-03-16T11:02:05+00:00" + "time": "2026-03-09T07:12:51+00:00" }, { "name": "brianium/paratest", @@ -8444,21 +8475,14 @@ "aliases": [ { "package": "utopia-php/database", - "version": "dev-main", + "version": "dev-fix-collection-recreate", "alias": "5.3.15", "alias_normalized": "5.3.15.0" - }, - { - "package": "utopia-php/framework", - "version": "dev-feat/coroutines-option", - "alias": "0.34.15", - "alias_normalized": "0.34.15.0" } ], "minimum-stability": "dev", "stability-flags": { - "utopia-php/database": 20, - "utopia-php/framework": 20 + "utopia-php/database": 20 }, "prefer-stable": true, "prefer-lowest": false, 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/mongo-entrypoint.sh b/mongo-entrypoint.sh old mode 100755 new mode 100644 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8825208851..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 - @@ -82308,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/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:')); + } +}