Merge origin/1.8.x into feat-user-impersonation
@@ -84,6 +84,31 @@ jobs:
|
||||
sarif_file: 'trivy-fs-results.sarif'
|
||||
category: 'trivy-source'
|
||||
|
||||
composer:
|
||||
name: Checks / Composer
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Validate
|
||||
run: composer validate
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Audit
|
||||
env:
|
||||
COMPOSER_NO_AUDIT: 0
|
||||
run: composer audit
|
||||
|
||||
format:
|
||||
name: Checks / Format
|
||||
runs-on: ubuntu-latest
|
||||
@@ -96,15 +121,18 @@ jobs:
|
||||
- run: git checkout HEAD^2
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Validate composer.json and composer.lock
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app composer:2.8 sh -c \
|
||||
"composer validate"
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Run Linter
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app composer:2.8 sh -c \
|
||||
"composer install --profile --ignore-platform-reqs && composer lint"
|
||||
run: composer lint
|
||||
|
||||
analyze:
|
||||
name: Checks / Analyze
|
||||
@@ -113,15 +141,33 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Run PHPStan
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app composer:2.8 sh -c \
|
||||
"composer install --profile --ignore-platform-reqs && composer analyze"
|
||||
run: composer analyze
|
||||
|
||||
locale:
|
||||
name: Checks / Locale
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Locale check
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app node:24-alpine sh -c \
|
||||
"cd /app/.github/workflows/static-analysis/locale && node index.js"
|
||||
run: node .github/workflows/static-analysis/locale/index.js
|
||||
|
||||
matrix:
|
||||
name: Tests / Matrix
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -167,7 +167,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int) $project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -188,7 +188,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int) $project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -211,9 +211,8 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
|
||||
$database = null;
|
||||
|
||||
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
|
||||
if ($database !== null && $project !== null && ! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int) $project->getSequence());
|
||||
|
||||
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant($project->getSequence());
|
||||
return $database;
|
||||
}
|
||||
|
||||
@@ -229,8 +228,8 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
|
||||
|
||||
// set tenant
|
||||
if ($project !== null && ! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int) $project->getSequence());
|
||||
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant($project->getSequence());
|
||||
}
|
||||
|
||||
return $database;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -215,7 +215,7 @@ Http::init()
|
||||
);
|
||||
}
|
||||
|
||||
if (! $dbKey) {
|
||||
if (!$dbKey) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
|
||||
@@ -614,7 +614,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int) $project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -873,7 +873,7 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int) $project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -903,9 +903,8 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
|
||||
$database = null;
|
||||
|
||||
return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
|
||||
if ($database !== null && $project !== null && ! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int) $project->getSequence());
|
||||
|
||||
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant($project->getSequence());
|
||||
return $database;
|
||||
}
|
||||
|
||||
@@ -921,8 +920,8 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
|
||||
|
||||
// set tenant
|
||||
if ($project !== null && ! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int) $project->getSequence());
|
||||
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant($project->getSequence());
|
||||
}
|
||||
|
||||
return $database;
|
||||
|
||||
@@ -50,6 +50,16 @@ require_once __DIR__ . '/init.php';
|
||||
|
||||
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
|
||||
|
||||
// Log uncaught exceptions in one line instead of relying on Swoole's full backtrace dump
|
||||
set_exception_handler(function (\Throwable $e) {
|
||||
Console::error(sprintf(
|
||||
'Realtime uncaught exception: %s in %s:%d',
|
||||
$e->getMessage(),
|
||||
$e->getFile(),
|
||||
$e->getLine()
|
||||
));
|
||||
});
|
||||
|
||||
// Allows overriding
|
||||
if (!function_exists('getConsoleDB')) {
|
||||
function getConsoleDB(): Database
|
||||
@@ -115,7 +125,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
|
||||
@@ -985,15 +995,21 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
});
|
||||
|
||||
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
|
||||
if (array_key_exists($connection, $realtime->connections)) {
|
||||
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
|
||||
$register->get('telemetry.connectionCounter')->add(-1);
|
||||
try {
|
||||
if (array_key_exists($connection, $realtime->connections)) {
|
||||
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
|
||||
$register->get('telemetry.connectionCounter')->add(-1);
|
||||
|
||||
$projectId = $realtime->connections[$connection]['projectId'];
|
||||
$projectId = $realtime->connections[$connection]['projectId'];
|
||||
|
||||
triggerStats([
|
||||
METRIC_REALTIME_CONNECTIONS => -1,
|
||||
], $projectId);
|
||||
triggerStats([
|
||||
METRIC_REALTIME_CONNECTIONS => -1,
|
||||
], $projectId);
|
||||
}
|
||||
} catch (\Throwable $th) {
|
||||
// Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine
|
||||
// backtraces and unsubscribe() below would never run (connection cleanup would fail).
|
||||
Console::error('Realtime onClose error: ' . $th->getMessage());
|
||||
}
|
||||
$realtime->unsubscribe($connection);
|
||||
|
||||
|
||||
@@ -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:
|
||||
<?php if ($version === 'local' && !empty($hostPath)): ?>
|
||||
- "<?php echo $hostPath; ?>:/usr/src/code:rw"
|
||||
<?php endif; ?>
|
||||
- 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
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
# - clamav
|
||||
environment:
|
||||
- _APP_ENV
|
||||
@@ -227,8 +236,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -258,8 +269,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -286,8 +299,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -316,8 +331,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
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
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -409,8 +428,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
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
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
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
|
||||
- <?= $dbService . "\n" ?>
|
||||
- openruntimes-executor
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
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
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
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
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
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:
|
||||
- <?= $dbService . "\n" ?>
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -688,8 +719,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -730,8 +763,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -761,8 +796,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -791,8 +828,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -821,8 +860,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- <?= $dbService . "\n" ?>
|
||||
- redis
|
||||
<?= $dbService ?>:
|
||||
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:
|
||||
- <?= $dbService . "\n" ?>
|
||||
- redis
|
||||
<?= $dbService ?>:
|
||||
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:
|
||||
- <?= $dbService . "\n" ?>
|
||||
- redis
|
||||
<?= $dbService ?>:
|
||||
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
|
||||
|
||||
<?php if ($dbService === 'mariadb'): ?>
|
||||
|
||||
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
|
||||
|
||||
<?php elseif ($dbService === 'mongodb'): ?>
|
||||
|
||||
@@ -1059,18 +1109,24 @@ $dbService = $this->getParam('database');
|
||||
<?php elseif ($dbService === 'postgresql'): ?>
|
||||
|
||||
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
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -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:
|
||||
<?php elseif ($dbService === 'mongodb'): ?>
|
||||
appwrite-mongodb:
|
||||
appwrite-mongodb-keyfile:
|
||||
appwrite-mongodb-config:
|
||||
<?php endif; ?>
|
||||
appwrite-redis:
|
||||
appwrite-cache:
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$lockedDatabase = $lockedDatabase ?? null;
|
||||
$vars = $vars ?? [];
|
||||
$defaultHttpPort = $defaultHttpPort ?? '80';
|
||||
$defaultHttpsPort = $defaultHttpsPort ?? '443';
|
||||
$defaultAppDomain = $vars['_APP_DOMAIN']['default'] ?? 'localhost';
|
||||
$defaultAppDomain = ($defaultAppDomain === 'traefik') ? 'localhost' : $defaultAppDomain;
|
||||
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
|
||||
$defaultDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? 'mongodb';
|
||||
$lockedDatabase = $isUpgrade && empty($lockedDatabase) ? $defaultDatabase : $lockedDatabase;
|
||||
$isLocalInstall = $isLocalInstall ?? false;
|
||||
|
||||
|
||||
$cardStep = min(4, $step);
|
||||
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
|
||||
if (!is_file($stepFile)) {
|
||||
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
|
||||
}
|
||||
$cssVersion = @filemtime(__DIR__ . '/installer/css/styles.css') ?: time();
|
||||
$tooltipsVersion = @filemtime(__DIR__ . '/installer/js/tooltips.js') ?: time();
|
||||
$constantsVersion = @filemtime(__DIR__ . '/installer/js/constants.js') ?: time();
|
||||
$stepsContextVersion = @filemtime(__DIR__ . '/installer/js/modules/context.js') ?: time();
|
||||
$stepsStateVersion = @filemtime(__DIR__ . '/installer/js/modules/state.js') ?: time();
|
||||
$stepsValidationVersion = @filemtime(__DIR__ . '/installer/js/modules/validation.js') ?: time();
|
||||
$stepsUiVersion = @filemtime(__DIR__ . '/installer/js/modules/ui.js') ?: time();
|
||||
$stepsToastVersion = @filemtime(__DIR__ . '/installer/js/modules/toast.js') ?: time();
|
||||
$stepsProgressVersion = @filemtime(__DIR__ . '/installer/js/modules/progress.js') ?: time();
|
||||
$stepsVersion = @filemtime(__DIR__ . '/installer/js/steps.js') ?: time();
|
||||
$installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?php echo $isUpgrade ? 'Appwrite Update' : 'Appwrite Installation'; ?></title>
|
||||
<meta name="appwrite-installer-csrf" content="<?php echo htmlspecialchars($csrfToken ?? '', ENT_QUOTES); ?>">
|
||||
<link rel="icon" type="image/svg+xml" href="installer/icons/appwrite-mark.svg">
|
||||
<link rel="stylesheet" href="installer/css/styles.css?v=<?php echo $cssVersion; ?>">
|
||||
<script src="installer/js/tooltips.js?v=<?php echo $tooltipsVersion; ?>" defer></script>
|
||||
<script src="installer/js/constants.js?v=<?php echo $constantsVersion; ?>" defer></script>
|
||||
<!-- Load context first; other modules depend on InstallerStepsContext. -->
|
||||
<script src="installer/js/modules/context.js?v=<?php echo $stepsContextVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/state.js?v=<?php echo $stepsStateVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/validation.js?v=<?php echo $stepsValidationVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/ui.js?v=<?php echo $stepsUiVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/toast.js?v=<?php echo $stepsToastVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/progress.js?v=<?php echo $stepsProgressVersion; ?>" defer></script>
|
||||
<script src="installer/js/steps.js?v=<?php echo $stepsVersion; ?>" defer></script>
|
||||
<script src="installer/js/installer.js?v=<?php echo $installerVersion; ?>" defer></script>
|
||||
</head>
|
||||
<body
|
||||
class="installer-page"
|
||||
data-step="<?php echo $step; ?>"
|
||||
data-upgrade="<?php echo $isUpgrade ? 'true' : 'false'; ?>"
|
||||
<?php if (!empty($lockedDatabase)) { ?>
|
||||
data-locked-database="<?php echo htmlspecialchars((string) $lockedDatabase, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
<?php } ?>
|
||||
data-default-http-port="<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-https-port="<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-app-domain="<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-email-certificates="<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-secret-key=""
|
||||
data-default-assistant-openai-key=""
|
||||
data-default-database="<?php echo htmlspecialchars((string) $defaultDatabase, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
<?php if ($isLocalInstall) { ?>
|
||||
data-dev-mode="true"
|
||||
<?php } ?>
|
||||
>
|
||||
<div class="installer-backdrop" aria-hidden="true">
|
||||
<span class="installer-gradients">
|
||||
<span class="installer-blob blob-one">
|
||||
<?php include __DIR__ . '/installer/icons/install-bg-1.svg'; ?>
|
||||
</span>
|
||||
<span class="installer-blob blob-two">
|
||||
<?php include __DIR__ . '/installer/icons/install-bg-2.svg'; ?>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<main class="installer-main">
|
||||
<div class="installer-card" data-step="<?php echo $step; ?>">
|
||||
<div class="installer-step">
|
||||
<div class="step-panel is-active">
|
||||
<?php include $stepFile; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-shell">
|
||||
<div class="divider"></div>
|
||||
<div class="action-bar">
|
||||
<button class="button secondary" type="button" data-action="back">
|
||||
<span class="button-text typography-text-m-500">Back</span>
|
||||
</button>
|
||||
|
||||
<div class="step-indicators" aria-hidden="true">
|
||||
<span class="step-indicator" data-step="1">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
<span class="step-indicator" data-step="2">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
<span class="step-indicator" data-step="3">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
<span class="step-indicator" data-step="4">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button class="button primary" type="button" data-action="next">
|
||||
<span class="button-text typography-text-m-500">Next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-screen" aria-live="polite">
|
||||
<div class="install-screen-content">
|
||||
<?php if ($step === 5) { include __DIR__ . '/installer/templates/steps/step-5.phtml'; } ?>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="installer-footer">
|
||||
<div class="appwrite-logo">
|
||||
<?php include __DIR__ . '/installer/icons/appwrite-logo.svg'; ?>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<template id="field-error-template">
|
||||
<div class="field-error typography-caption-400 text-error">
|
||||
<span class="field-error-icon">
|
||||
<?php include __DIR__ . '/installer/icons/exclamation-circle.svg'; ?>
|
||||
</span>
|
||||
<span class="field-error-text"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="installer-toast-stack" class="installer-toast-stack" aria-live="polite" aria-atomic="true"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg width="131" height="25" viewBox="0 0 131 25" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M38.2643 19.5087C40.4069 19.5087 41.49 18.3947 41.9609 17.6362H42.1728C42.267 18.4421 42.832 19.2006 43.9386 19.2006H46.0341V16.8304H45.4926C45.1159 16.8304 44.9275 16.617 44.9275 16.2852V6.78055H42.1492V8.29751H41.9373C41.3958 7.53903 40.2656 6.47242 38.1937 6.47242C34.8974 6.47242 32.4487 9.2219 32.4487 12.9906C32.4487 16.7593 34.9445 19.5087 38.2643 19.5087ZM38.7588 16.8067C36.8045 16.8067 35.2741 15.3608 35.2741 13.0143C35.2741 10.7151 36.7574 9.15079 38.7352 9.15079C40.6188 9.15079 42.1963 10.5492 42.1963 13.0143C42.1963 15.1238 40.8543 16.8067 38.7588 16.8067Z" fill="#19191D"/>
|
||||
<path d="M47.6745 24.0166H50.4528V17.6362H50.6647C51.1826 18.3947 52.2893 19.5087 54.4789 19.5087C57.7752 19.5087 60.1768 16.7118 60.1768 12.9906C60.1768 9.2456 57.6104 6.47242 54.2906 6.47242C52.1715 6.47242 51.1356 7.63384 50.6411 8.2738H50.4292V6.78055H47.6745V24.0166ZM53.8903 16.8778C51.9832 16.8778 50.4057 15.4556 50.4057 12.9906C50.4057 10.8811 51.7477 9.10339 53.8432 9.10339C55.7974 9.10339 57.3279 10.644 57.3279 12.9906C57.3279 15.2897 55.8445 16.8778 53.8903 16.8778Z" fill="#19191D"/>
|
||||
<path d="M61.6104 24.0166H64.3887V17.6362H64.6006C65.1186 18.3947 66.2252 19.5087 68.4149 19.5087C71.7112 19.5087 73.8839 16.7118 73.8839 12.9906C73.8839 9.2456 71.5464 6.47242 68.2265 6.47242C66.1075 6.47242 65.0715 7.63384 64.5771 8.2738H64.3652V6.78055H61.6104V24.0166ZM67.8263 16.8778C65.9191 16.8778 64.3416 15.4556 64.3416 12.9906C64.3416 10.8811 65.6837 9.10339 67.7792 9.10339C69.7334 9.10339 71.2638 10.644 71.2638 12.9906C71.2638 15.2897 69.7805 16.8778 67.8263 16.8778Z" fill="#19191D"/>
|
||||
<path d="M77.5565 19.489H81.4885L83.7252 9.74733H83.8665L86.1033 19.489H90.0117L93.1415 7.06896H90.3414L88.1046 16.8343H87.8927L85.6559 7.06896H81.9594L79.6991 16.8343H79.4872L77.2739 7.06896H74.3073L77.5565 19.489Z" fill="#19191D"/>
|
||||
<path d="M94.549 19.489H97.3273V13.3501C97.3273 11.0036 98.4104 9.55771 100.435 9.55771H101.66V6.76083H100.741C99.1638 6.76083 97.963 7.85114 97.4921 8.89405H97.3038V7.06896H94.549V19.489Z" fill="#19191D"/>
|
||||
<path d="M115.447 19.489H117.613V17.0003H115.47C114.623 17.0003 114.27 16.621 114.27 15.744V9.53401H117.754V7.06896H114.27V3.58472H111.633V7.06896H109.325V9.53401H111.468V15.7677C111.468 18.3987 113.045 19.489 115.447 19.489Z" fill="#19191D"/>
|
||||
<path d="M125.067 19.5087C127.633 19.5087 129.893 18.2288 130.694 15.6452L128.151 15.029C127.704 16.4037 126.409 17.1148 125.043 17.1148C123.018 17.1148 121.676 15.7875 121.653 13.7016H131V12.9195C131 9.2219 128.716 6.47242 124.949 6.47242C121.629 6.47242 118.78 9.10339 118.78 13.0143C118.78 16.8067 121.3 19.5087 125.067 19.5087ZM121.676 11.6632C121.841 10.17 123.183 8.91377 124.949 8.91377C126.644 8.91377 128.033 9.98037 128.175 11.6632H121.676Z" fill="#19191D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.09 19.489H105.312V9.53401H103.145V7.06896H108.09V19.489Z" fill="#19191D"/>
|
||||
<path d="M106.494 5.34533C107.507 5.34533 108.26 4.58686 108.26 3.59136C108.26 2.61956 107.507 1.86108 106.494 1.86108C105.482 1.86108 104.729 2.61956 104.729 3.59136C104.729 4.58686 105.482 5.34533 106.494 5.34533Z" fill="#19191D"/>
|
||||
<path d="M24.2577 16.4436V21.9248H10.6705C6.71194 21.9248 3.25559 19.7204 1.40636 16.4436C1.13754 15.9672 0.90225 15.4674 0.704883 14.9487C0.31744 13.9322 0.0738912 12.8415 0 11.7034V10.2214C0.0160422 9.96781 0.0413207 9.71617 0.0743773 9.46752C0.141949 8.95727 0.244035 8.45799 0.378206 7.97265C1.64748 3.37143 5.77469 0 10.6705 0C15.5662 0 19.693 3.37143 20.9622 7.97265H15.1526C14.1988 6.47279 12.5479 5.4812 10.6705 5.4812C8.79305 5.4812 7.14216 6.47279 6.18839 7.97265C5.89768 8.42859 5.67212 8.93136 5.52434 9.46752C5.39308 9.94289 5.32308 10.4442 5.32308 10.9624C5.32308 12.5335 5.96768 13.9497 7.00119 14.9487C7.95886 15.876 9.25 16.4436 10.6705 16.4436H24.2577Z" fill="#FD366E"/>
|
||||
<path d="M24.2578 9.46753V14.9487H14.3398C15.3733 13.9497 16.018 12.5335 16.018 10.9624C16.018 10.4442 15.9479 9.9429 15.8167 9.46753H24.2578Z" fill="#FD366E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="22" viewBox="0 0 25 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M24.2577 16.4436V21.9248H10.6705C6.71194 21.9248 3.25559 19.7204 1.40636 16.4436C1.13754 15.9672 0.90225 15.4674 0.704883 14.9487C0.31744 13.9322 0.0738912 12.8415 0 11.7034V10.2214C0.0160422 9.96781 0.0413207 9.71617 0.0743773 9.46752C0.141949 8.95727 0.244035 8.45799 0.378206 7.97265C1.64748 3.37143 5.77469 0 10.6705 0C15.5662 0 19.693 3.37143 20.9622 7.97265H15.1526C14.1988 6.47279 12.5479 5.4812 10.6705 5.4812C8.79305 5.4812 7.14216 6.47279 6.18839 7.97265C5.89768 8.42859 5.67212 8.93136 5.52434 9.46752C5.39308 9.94289 5.32308 10.4442 5.32308 10.9624C5.32308 12.5335 5.96768 13.9497 7.00119 14.9487C7.95886 15.876 9.25 16.4436 10.6705 16.4436H24.2577Z" fill="#FD366E"/>
|
||||
<path d="M24.2578 9.46753V14.9487H14.3398C15.3733 13.9497 16.018 12.5335 16.018 10.9624C16.018 10.4442 15.9479 9.9429 15.8167 9.46753H24.2578Z" fill="#FD366E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -0,0 +1,3 @@
|
||||
<svg class="accordion-chevron" data-open="false" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.29289 7.29289C5.68342 6.90237 6.31658 6.90237 6.70711 7.29289L10 10.5858L13.2929 7.29289C13.6834 6.90237 14.3166 6.90237 14.7071 7.29289C15.0976 7.68342 15.0976 8.31658 14.7071 8.70711L10.7071 12.7071C10.3166 13.0976 9.68342 13.0976 9.29289 12.7071L5.29289 8.70711C4.90237 8.31658 4.90237 7.68342 5.29289 7.29289Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill="currentColor" d="M7 9a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z"/>
|
||||
<path fill="currentColor" d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2V5h8a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M3.707 2.293a1 1 0 1 0-1.414 1.414l14 14a1 1 0 1 0 1.414-1.414l-1.473-1.473A10 10 0 0 0 19.543 10C18.268 5.943 14.478 3 10 3a9.96 9.96 0 0 0-4.512 1.074zm4.261 4.26 1.514 1.515a2.003 2.003 0 0 1 2.45 2.45l1.515 1.514a4 4 0 0 0-5.478-5.478" clip-rule="evenodd"/>
|
||||
<path fill="currentColor" d="M12.454 16.697 9.75 13.992a4 4 0 0 1-3.742-3.742L2.335 6.578A10 10 0 0 0 .458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.67-.105 2.454-.303"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M10 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M.457 10C1.732 5.943 5.522 3 10 3s8.267 2.943 9.542 7c-1.275 4.057-5.065 7-9.542 7-4.478 0-8.268-2.943-9.543-7M14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8Z" fill="#FD366E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8Z" fill="#D8D8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0M9 9a1 1 0 1 0 0 2v3a1 1 0 0 0 1 1h1a1 1 0 1 0 0-2v-3a1 1 0 0 0-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="408" height="353" viewBox="0 0 408 353" fill="none" aria-hidden="true">
|
||||
<g filter="url(#filter0_f_3091_1065)">
|
||||
<path d="M216.4 88.698C162.554 64.5014 104.764 118.126 82.6001 147.963C90.6227 156.819 114.663 172.323 146.645 163.495C186.622 152.459 191.109 162.269 229.454 181.887C267.799 201.506 268.207 261.18 291.051 268.537C313.895 275.894 322.054 244.422 324.501 193.74C326.949 143.058 283.709 118.944 216.4 88.698Z" fill="url(#paint0_radial_3091_1065)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_3091_1065" x="9.91821e-05" y="-2.28882e-05" width="407.2" height="352.2" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="41.3" result="effect1_foregroundBlur_3091_1065"/>
|
||||
</filter>
|
||||
<radialGradient id="paint0_radial_3091_1065" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(203.6 176.1) rotate(-90) scale(93.5 121)">
|
||||
<stop stop-color="#FE9567"/>
|
||||
<stop offset="1" stop-color="#FE9567" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="569" height="367" viewBox="0 0 569 367" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<g filter="url(#install-bg-2-filter)">
|
||||
<path d="M136.265 227.973C89.0703 207.713 99.6672 144.647 110.865 115.646C130.119 111.289 176.904 102.33 210.005 101.35C251.382 100.125 288.253 121.773 333.317 135.252C378.381 148.731 466.051 125.449 467.28 160.577C468.509 195.704 412.793 254.522 402.142 242.269C391.49 230.015 319.798 227.973 246.057 254.522C172.316 281.072 195.257 253.297 136.265 227.973Z" fill="url(#install-bg-2-paint)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="install-bg-2-filter" x="-0.000198364" y="0.0000457764" width="568.6" height="366.6" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="50.65" result="effect1_foregroundBlur_install_bg_2"/>
|
||||
</filter>
|
||||
<radialGradient id="install-bg-2-paint" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(284.3 183.3) rotate(-90) scale(82 183)">
|
||||
<stop stop-color="#FD366E"/>
|
||||
<stop offset="1" stop-color="#FD366E" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle class="check-bg" cx="8" cy="8" r="7" fill="var(--fgcolor-neutral-primary)"/>
|
||||
<path class="check-mark" d="M5 8.2L7.2 10.2L11.2 6.2" fill="none" stroke="var(--fgcolor-on-invert)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path class="spinner-ring" opacity="0.2" d="M14.4001 7.99998C14.4001 11.5346 11.5347 14.4 8.0001 14.4C4.46548 14.4 1.6001 11.5346 1.6001 7.99998C1.6001 4.46535 4.46548 1.59998 8.0001 1.59998C11.5347 1.59998 14.4001 4.46535 14.4001 7.99998ZM2.8801 7.99998C2.8801 10.8277 5.1724 13.12 8.0001 13.12C10.8278 13.12 13.1201 10.8277 13.1201 7.99998C13.1201 5.17228 10.8278 2.87998 8.0001 2.87998C5.1724 2.87998 2.8801 5.17228 2.8801 7.99998Z" fill="var(--fgcolor-neutral-secondary)"/>
|
||||
<path class="spinner-arc" d="M4.32921 2.7574C5.15654 2.1781 6.10923 1.80282 7.10939 1.66226C8.10955 1.5217 9.12877 1.61984 10.0837 1.94866C11.0387 2.27748 11.9023 2.82764 12.6039 3.55416C13.3055 4.28069 13.8252 5.16294 14.1204 6.1288C14.4157 7.09465 14.4782 8.11668 14.3029 9.11133C14.1275 10.106 13.7192 11.045 13.1114 11.8516C12.5035 12.6582 11.7134 13.3095 10.8057 13.7523C9.8979 14.195 8.89823 14.4166 7.8884 14.399L7.91074 13.1192C8.71861 13.1333 9.51834 12.956 10.2446 12.6018C10.9708 12.2476 11.6029 11.7266 12.0891 11.0813C12.5754 10.436 12.902 9.68477 13.0423 8.88906C13.1826 8.09334 13.1326 7.27572 12.8964 6.50303C12.6601 5.73035 12.2444 5.02454 11.6831 4.44333C11.1218 3.86211 10.431 3.42198 9.66701 3.15892C8.90304 2.89586 8.08766 2.81735 7.28753 2.9298C6.48741 3.04225 5.72525 3.34247 5.06339 3.80592L4.32921 2.7574Z" fill="var(--fgcolor-neutral-secondary)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M5 9V7a5 5 0 1 1 10 0v2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m8-2v2H7V7a3 3 0 1 1 6 0" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="33" height="33" rx="8.5" fill="white" stroke="#EDEDF0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.3208 9.43304C27.9548 9.4451 28.0607 9.61 27.2684 9.80574C26.468 10.0028 25.5 9.88216 24.6393 10.2441C22.387 11.188 22.0224 14.8372 19.3397 16.249C17.5807 17.2424 15.7869 17.4703 14.1835 17.973C12.8965 18.4892 12.0961 18.8311 11.1523 19.6448C10.4203 20.2763 10.242 20.889 9.47647 21.6813C8.70022 22.7364 5.75747 21.7993 5 23.1225C5.39952 23.3813 5.6301 23.4523 6.33127 23.3598C6.18647 23.6346 5.26411 23.9966 5.43169 24.4015C5.43169 24.4015 7.66121 24.8077 9.54081 23.6735C10.4176 23.3169 11.2394 22.5621 12.6015 22.3811C14.3658 22.1465 16.354 22.7512 18.487 22.9214C18.046 23.7928 17.5915 24.3077 17.1048 25.0263C16.9547 25.1885 17.2335 25.3319 17.751 25.2341C18.6814 25.0035 19.3558 24.7541 20.0248 24.2916C20.8975 23.6896 21.2729 23.1386 22.009 22.2658C22.6484 23.2914 24.9034 23.5167 25.366 22.6305C24.5053 22.2658 24.3216 20.3688 24.6165 19.5497C24.9651 18.7694 25.2158 17.666 25.4974 16.6404C25.7507 15.7167 25.9076 14.3077 26.2106 13.5864C26.5726 12.6895 27.2764 12.4093 27.8046 11.9334C28.3328 11.4574 28.8584 11.0606 28.8423 9.97198C28.8369 9.61805 28.6546 9.42231 28.3208 9.43304Z" fill="#1F305F"/>
|
||||
<path d="M5.673 24.2137C7.05522 24.2553 7.43061 24.2191 8.52324 23.7445C9.45232 23.341 10.6951 22.2537 11.7931 21.9024C13.4046 21.3849 15.1327 21.4627 16.8474 21.6651C17.4212 21.7335 17.9977 21.8287 18.4093 21.7844C19.0514 21.3903 19.0823 20.3204 19.4805 20.2346C19.3719 22.3046 18.4843 23.6292 17.5968 24.8304C19.467 24.5006 20.7165 23.3329 21.4727 21.8877C21.7019 21.4493 22.0827 20.732 22.257 20.2387C22.395 20.5658 22.0773 20.7736 22.2261 21.1302C23.43 20.1368 23.9985 19.0053 24.4865 17.3482C25.0522 15.4311 25.6327 13.7834 25.9974 13.2163C26.3527 12.6626 26.9064 12.3207 27.4118 11.9668C27.9856 11.5633 28.4991 11.1436 28.5875 10.3754C27.9816 10.3191 27.8421 10.1797 27.7523 9.87402C27.4493 10.0443 27.1705 10.0818 26.8554 10.0912C26.5819 10.0993 26.2816 10.0872 25.9143 10.1247C22.8804 10.4358 22.7396 13.4013 20.5342 15.5785C20.3921 15.7166 20.1494 15.9204 19.9886 16.0411C19.3075 16.5492 18.5782 16.828 17.81 17.1216C16.5658 17.5962 15.3861 17.7343 14.2197 18.1446C13.363 18.4449 12.5667 18.7894 11.8628 19.2707C11.6872 19.3914 11.4486 19.6059 11.2957 19.7306C10.8828 20.0684 10.612 20.4424 10.3492 20.8286C10.0784 21.2254 9.81831 21.6343 9.42014 22.0244C8.77528 22.6572 6.36746 22.2094 5.51882 22.7966C5.42497 22.8623 5.34855 22.9401 5.29761 23.0339C5.76014 23.2444 6.06982 23.1157 6.60207 23.1734C6.66776 23.6815 5.50005 23.9818 5.673 24.2137Z" fill="#C0765A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.9033 11.3797C25.3323 11.7524 26.2333 11.4534 26.0724 10.712C25.4047 10.6557 25.0186 10.8823 24.9033 11.3797Z" fill="#1F305F"/>
|
||||
<path d="M27.8971 10.5123C27.7831 10.7523 27.5646 11.0606 27.5646 11.6706C27.5632 11.7752 27.4855 11.8476 27.4841 11.6854C27.4895 11.0888 27.6477 10.8314 27.8153 10.4935C27.8917 10.3554 27.9386 10.4117 27.8971 10.5123Z" fill="#1F305F"/>
|
||||
<path d="M27.7818 10.4225C27.6464 10.6504 27.322 11.0674 27.2697 11.676C27.2603 11.7806 27.1745 11.845 27.1879 11.6841C27.2469 11.0915 27.5057 10.7202 27.7028 10.3971C27.7912 10.2657 27.8328 10.326 27.7818 10.4225Z" fill="#1F305F"/>
|
||||
<path d="M27.6772 10.303C27.523 10.5189 27.0243 11.0203 26.9197 11.6209C26.901 11.7241 26.8112 11.7804 26.838 11.6222C26.9466 11.0364 27.3769 10.5752 27.5995 10.2708C27.6987 10.1462 27.7362 10.2105 27.6772 10.303Z" fill="#1F305F"/>
|
||||
<path d="M27.5835 10.1688C27.4012 10.3619 26.8059 11 26.6182 11.5805C26.5847 11.6797 26.4882 11.724 26.5365 11.5698C26.7255 11.0041 27.247 10.3954 27.5111 10.1259C27.6278 10.0173 27.6546 10.087 27.5835 10.1688Z" fill="#1F305F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#081E2B"/>
|
||||
<g transform="translate(11.4, 6.1)">
|
||||
<path d="M6.03701 2.19958C5.24603 1.21687 4.56489 0.218806 4.42574 0.0115161C4.41109 -0.00383871 4.38911 -0.00383871 4.37447 0.0115161C4.23531 0.218806 3.55418 1.21687 2.76319 2.19958C-4.02614 11.2666 3.8325 17.3856 3.8325 17.3856L3.89841 17.4316C3.95699 18.3759 4.10348 19.7348 4.10348 19.7348H4.39644H4.6894C4.6894 19.7348 4.83588 18.3836 4.89447 17.4316L4.96038 17.3779C4.9677 17.3779 12.8264 11.2666 6.03701 2.19958ZM4.39644 17.2473C4.39644 17.2473 4.04489 16.9325 3.94968 16.7713V16.756L4.37447 6.88281C4.37447 6.8521 4.41842 6.8521 4.41842 6.88281L4.8432 16.756V16.7713C4.74799 16.9325 4.39644 17.2473 4.39644 17.2473Z" fill="#00ED64"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 861 B |
@@ -0,0 +1,27 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="31" height="31" rx="7.5" fill="white" stroke="#EDEDF0"/>
|
||||
<g transform="translate(3.2, 3.2) scale(1)">
|
||||
<svg viewBox="0 0 25.6 25.6" width="25.6" height="25.6">
|
||||
<style><![CDATA[.B{stroke-linecap:round}.C{stroke-linejoin:round}.D{stroke-linejoin:miter}.E{stroke-width:.716}]]></style>
|
||||
<g fill="none" stroke="#fff">
|
||||
<path d="M18.983 18.636c.163-1.357.114-1.555 1.124-1.336l.257.023c.777.035 1.793-.125 2.4-.402 1.285-.596 2.047-1.592.78-1.33-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.227-11.687-3.004-3.84-8.205-2.024-8.292-1.976l-.028.005c-.57-.12-1.2-.19-1.93-.2-1.308-.02-2.3.343-3.054.914 0 0-9.277-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.01 2.01 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.285 1.76.33 2.842s.116 2.093.337 2.688.48 2.13 2.53 1.7c1.713-.367 3.023-.896 3.143-5.81" fill="#000" stroke="#000" stroke-linecap="butt" stroke-width="2.149" class="D"/>
|
||||
<path d="M23.535 15.6c-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.228-11.687-3.004-3.84-8.205-2.023-8.292-1.976l-.028.005a10.31 10.31 0 0 0-1.929-.201c-1.308-.02-2.3.343-3.054.914 0 0-9.278-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.02 2.02 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.52 1.593.484 2.815s-.06 2.06.18 2.716.48 2.13 2.53 1.7c1.713-.367 2.6-1.32 2.725-2.906.088-1.128.286-.962.3-1.97l.16-.478c.183-1.53.03-2.023 1.085-1.793l.257.023c.777.035 1.794-.125 2.39-.402 1.285-.596 2.047-1.592.78-1.33z" fill="#336791" stroke="none"/>
|
||||
<g class="E">
|
||||
<g class="B">
|
||||
<path d="M12.814 16.467c-.08 2.846.02 5.712.298 6.4s.875 2.05 2.926 1.612c1.713-.367 2.337-1.078 2.607-2.647l.633-5.017M10.356 2.2S1.072-1.596 1.504 7.033c.092 1.836 2.63 13.9 5.66 10.25C8.27 15.95 9.27 14.907 9.27 14.907m6.1-13.4c-.32.1 5.164-2.005 8.282 1.978 1.1 1.407-.175 7.157-3.228 11.687" class="C"/>
|
||||
<path d="M20.425 15.17s.2.98 3.1.382c1.267-.262.504.734-.78 1.33-1.054.49-3.418.615-3.457-.06-.1-1.745 1.244-1.215 1.147-1.652-.088-.394-.69-.78-1.086-1.744-.347-.84-4.76-7.29 1.224-6.333.22-.045-1.56-5.7-7.16-5.782S7.99 8.196 7.99 8.196" stroke-linejoin="bevel"/>
|
||||
</g>
|
||||
<g class="C">
|
||||
<path d="M11.247 15.768c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.163.35-.49-.002-1.27-.482-1.468-.232-.096-.542-.216-.94.23z"/>
|
||||
<path d="M11.196 15.753c-.08-.513.168-1.122.433-1.836.398-1.07 1.316-2.14.582-5.537-.547-2.53-4.22-.527-4.22-.184s.166 1.74-.06 3.365c-.297 2.122 1.35 3.916 3.246 3.733" class="B"/>
|
||||
</g>
|
||||
</g>
|
||||
<g fill="#fff" class="D">
|
||||
<path d="M10.322 8.145c-.017.117.215.43.516.472s.558-.202.575-.32-.215-.246-.516-.288-.56.02-.575.136z" stroke-width=".239"/>
|
||||
<path d="M19.486 7.906c.016.117-.215.43-.516.472s-.56-.202-.575-.32.215-.246.516-.288.56.02.575.136z" stroke-width=".119"/>
|
||||
</g>
|
||||
<path d="M20.562 7.095c.05.92-.198 1.545-.23 2.524-.046 1.422.678 3.05-.413 4.68" class="B C E"/>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.99976 2C4.55204 2 4.99976 2.44772 4.99976 3V5.10125C6.26985 3.80489 8.04028 3 9.99976 3C13.0492 3 15.6407 4.94932 16.6012 7.66675C16.7852 8.18747 16.5123 8.75879 15.9916 8.94284C15.4709 9.12689 14.8996 8.85396 14.7155 8.33325C14.0286 6.38991 12.1752 5 9.99976 5C8.36482 5 6.91179 5.78502 5.99911 7H8.99976C9.55204 7 9.99976 7.44771 9.99976 8C9.99976 8.55228 9.55204 9 8.99976 9H3.99976C3.44747 9 2.99976 8.55228 2.99976 8V3C2.99976 2.44772 3.44747 2 3.99976 2ZM4.00792 11.0572C4.52864 10.8731 5.09996 11.146 5.28401 11.6668C5.97088 13.6101 7.82429 15 9.99976 15C11.6347 15 13.0877 14.215 14.0004 13L10.9998 13C10.4475 13 9.99976 12.5523 9.99976 12C9.99976 11.4477 10.4475 11 10.9998 11H15.9998C16.265 11 16.5193 11.1054 16.7069 11.2929C16.8944 11.4804 16.9998 11.7348 16.9998 12V17C16.9998 17.5523 16.552 18 15.9998 18C15.4475 18 14.9998 17.5523 14.9998 17V14.8987C13.7297 16.1951 11.9592 17 9.99976 17C6.95035 17 4.3588 15.0507 3.39833 12.3332C3.21428 11.8125 3.48721 11.2412 4.00792 11.0572Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 157 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.25706 3.09882C9.02167 1.73952 10.9788 1.73952 11.7434 3.09882L17.3237 13.0194C18.0736 14.3526 17.1102 15.9999 15.5805 15.9999H4.4199C2.89025 15.9999 1.92682 14.3526 2.67675 13.0194L8.25706 3.09882ZM11.0001 13C11.0001 13.5523 10.5524 14 10.0001 14C9.44784 14 9.00012 13.5523 9.00012 13C9.00012 12.4477 9.44784 12 10.0001 12C10.5524 12 11.0001 12.4477 11.0001 13ZM10.0001 5C9.44784 5 9.00012 5.44772 9.00012 6V9C9.00012 9.55228 9.44784 10 10.0001 10C10.5524 10 11.0001 9.55228 11.0001 9V6C11.0001 5.44772 10.5524 5 10.0001 5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1,11 @@
|
||||
(() => {
|
||||
window.InstallerConstants = Object.freeze({
|
||||
stepTransitionMs: 260,
|
||||
errorClearMs: 180,
|
||||
installPollIntervalMs: 4000,
|
||||
installFallbackDelayMs: 12000,
|
||||
redirectDelayMs: 2500,
|
||||
progressTransitionDelayMs: 320,
|
||||
progressCompleteDelayMs: 140,
|
||||
});
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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
|
||||
});
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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 || (() => '')
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
(() => {
|
||||
const TOAST_STACK_ID = 'installer-toast-stack';
|
||||
const DEFAULT_TIMEOUT = 5000;
|
||||
const MAX_TOASTS = 3;
|
||||
const ICONS = {
|
||||
error: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" clip-rule="evenodd"/></svg>',
|
||||
close: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M5.293 5.293a1 1 0 0 1 1.414 0L10 8.586l3.293-3.293a1 1 0 1 1 1.414 1.414L11.414 10l3.293 3.293a1 1 0 0 1-1.414 1.414L10 11.414l-3.293 3.293a1 1 0 0 1-1.414-1.414L8.586 10 5.293 6.707a1 1 0 0 1 0-1.414" clip-rule="evenodd"/></svg>'
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$lockedDatabase = $lockedDatabase ?? null;
|
||||
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
|
||||
$defaultHttpPort = $defaultHttpPort ?? '80';
|
||||
$defaultHttpsPort = $defaultHttpsPort ?? '443';
|
||||
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
|
||||
$defaultAssistantOpenAIKey = $defaultAssistantOpenAIKey ?? '';
|
||||
$defaultDatabase = $defaultDatabase ?? 'mongodb';
|
||||
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
|
||||
$isDatabaseLocked = !empty($lockedDatabase);
|
||||
$mongoDisabled = $isDatabaseLocked && $selectedDatabase !== 'mongodb';
|
||||
$mariaDisabled = $isDatabaseLocked && $selectedDatabase !== 'mariadb';
|
||||
$postgresDisabled = $isDatabaseLocked && $selectedDatabase !== 'postgresql';
|
||||
$hostnameValue = htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8');
|
||||
$httpPortValue = htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8');
|
||||
$httpsPortValue = htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8');
|
||||
$sslEmailValue = htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8');
|
||||
$assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="step-layout" data-step="1">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Update your app' : 'Setup your app'; ?></h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
<?php echo $isUpgrade ? 'Review your current settings before updating.' : 'Choose where your app will live and how it will store data'; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-xl">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="hostname" class="label-text typography-text-m-500 text-neutral-secondary">Hostname</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="localhost"
|
||||
value="<?php echo $hostnameValue; ?>"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="input-group stack-xs">
|
||||
<label class="label-text typography-text-m-500 text-neutral-secondary">Database</label>
|
||||
<div class="selector-group<?php echo $isDatabaseLocked ? ' is-locked' : ''; ?>">
|
||||
<label class="selector-card <?php echo ($selectedDatabase === 'mongodb') ? 'selected' : ''; ?><?php echo $mongoDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $mongoDisabled ? ' aria-disabled="true"' : ''; ?>>
|
||||
<input type="radio" name="database" value="mongodb" <?php echo ($selectedDatabase === 'mongodb') ? 'checked' : ''; ?> class="sr-only" <?php echo $mongoDisabled ? 'disabled' : ''; ?>>
|
||||
<div class="selector-content">
|
||||
<div class="typography-text-m-500 text-neutral-primary">MongoDB</div>
|
||||
<div class="typography-text-xs-400 text-neutral-secondary">Document based database</div>
|
||||
</div>
|
||||
<img src="installer/icons/mongodb.svg" alt="MongoDB" class="selector-icon">
|
||||
<?php if ($mongoDisabled) { ?>
|
||||
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
|
||||
<?php } ?>
|
||||
</label>
|
||||
|
||||
<label class="selector-card <?php echo ($selectedDatabase === 'mariadb') ? 'selected' : ''; ?><?php echo $mariaDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $mariaDisabled ? ' aria-disabled="true"' : ''; ?>>
|
||||
<input type="radio" name="database" value="mariadb" <?php echo ($selectedDatabase === 'mariadb') ? 'checked' : ''; ?> class="sr-only" <?php echo $mariaDisabled ? 'disabled' : ''; ?>>
|
||||
<div class="selector-content">
|
||||
<div class="typography-text-m-500 text-neutral-primary">MariaDB</div>
|
||||
<div class="typography-text-xs-400 text-neutral-secondary">Relational SQL database</div>
|
||||
</div>
|
||||
<img src="installer/icons/mariadb.svg" alt="MariaDB" class="selector-icon">
|
||||
<?php if ($mariaDisabled) { ?>
|
||||
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
|
||||
<?php } ?>
|
||||
</label>
|
||||
|
||||
<label class="selector-card <?php echo ($selectedDatabase === 'postgresql') ? 'selected' : ''; ?><?php echo $postgresDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $postgresDisabled ? ' aria-disabled="true"' : ''; ?>>
|
||||
<input type="radio" name="database" value="postgresql" <?php echo ($selectedDatabase === 'postgresql') ? 'checked' : ''; ?> class="sr-only" <?php echo $postgresDisabled ? 'disabled' : ''; ?>>
|
||||
<div class="selector-content">
|
||||
<div class="typography-text-m-500 text-neutral-primary">PostgreSQL</div>
|
||||
<div class="typography-text-xs-400 text-neutral-secondary">Relational SQL database</div>
|
||||
</div>
|
||||
<img src="installer/icons/postgresql.svg" alt="PostgreSQL" class="selector-icon">
|
||||
<?php if ($postgresDisabled) { ?>
|
||||
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
|
||||
<?php } ?>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-toggle"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="label-text typography-text-m-500 text-neutral-secondary">Advanced settings</span>
|
||||
<span class="accordion-chevron" data-open="false">
|
||||
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
|
||||
</span>
|
||||
</button>
|
||||
<div class="accordion-content">
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="http-port" class="label-text typography-text-m-500 text-neutral-secondary">HTTP port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="http-port"
|
||||
name="httpPort"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="<?php echo $httpPortValue; ?>"
|
||||
value="<?php echo $httpPortValue; ?>"
|
||||
data-default="<?php echo $httpPortValue; ?>"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="button secondary" data-reset-target="http-port" disabled>
|
||||
<span class="button-text typography-text-m-500">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="https-port" class="label-text typography-text-m-500 text-neutral-secondary">HTTPS port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="https-port"
|
||||
name="httpsPort"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="<?php echo $httpsPortValue; ?>"
|
||||
value="<?php echo $httpsPortValue; ?>"
|
||||
data-default="<?php echo $httpsPortValue; ?>"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="button secondary" data-reset-target="https-port" disabled>
|
||||
<span class="button-text typography-text-m-500">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="ssl-email" class="label-text typography-text-m-500 text-neutral-secondary">SSL certificate email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="ssl-email"
|
||||
name="sslEmail"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="you@example.com"
|
||||
value="<?php echo $sslEmailValue; ?>"
|
||||
data-default="<?php echo $sslEmailValue; ?>"
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="button secondary" data-reset-target="ssl-email" disabled>
|
||||
<span class="button-text typography-text-m-500">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="assistant-openai-key" class="label-text typography-text-m-500 text-neutral-secondary">
|
||||
OpenAI key
|
||||
<span class="label-optional typography-text-m-400 text-neutral-tertiary">optional</span>
|
||||
<span class="tooltip-wrapper">
|
||||
<button type="button" class="label-info-button" aria-label="OpenAI key info">
|
||||
<?php include __DIR__ . '/../../icons/info.svg'; ?>
|
||||
</button>
|
||||
<span class="tooltip tooltip-assistant typography-text-m-400 text-on-invert" role="tooltip">Add your OpenAI key to enable Appwrite Assistant. The key is stored locally and won’t be shared.</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="assistant-openai-key"
|
||||
name="assistantOpenAIKey"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="Enter OpenAI key for Appwrite Assistant"
|
||||
value="<?php echo $assistantOpenAIKeyValue; ?>"
|
||||
data-default="<?php echo $assistantOpenAIKeyValue; ?>"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$defaultSecretKey = $defaultSecretKey ?? '';
|
||||
$secretKeyValue = htmlspecialchars((string) $defaultSecretKey, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="step-layout" data-step="2">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary">Secure your app</h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
<?php echo $isUpgrade ? 'Review your existing secret key or generate a new one.' : 'Your server\'s private secret key for encryption. Generate a random secure key, or provide your own.'; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-xl">
|
||||
<div class="inline-alert inline-alert--warning">
|
||||
<div class="inline-alert-content">
|
||||
<div class="inline-alert-icon" aria-hidden="true">
|
||||
<?php include __DIR__ . '/../../icons/warning.svg'; ?>
|
||||
</div>
|
||||
<div class="inline-alert-text">
|
||||
<div class="inline-alert-title typography-text-m-500 text-warning">Save your key</div>
|
||||
<div class="inline-alert-description typography-text-m-400 text-neutral-primary">You won't be able to see this key again. Copy it somewhere safe before continuing.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="secret-key" class="label-text typography-text-m-500 text-neutral-secondary">Secret API key</label>
|
||||
<div class="input-action">
|
||||
<input
|
||||
type="text"
|
||||
id="secret-key"
|
||||
name="secretKey"
|
||||
class="input-action-input typography-text-m-400 text-neutral-primary"
|
||||
value="<?php echo $secretKeyValue; ?>"
|
||||
minlength="1"
|
||||
maxlength="64"
|
||||
>
|
||||
<div class="input-action-buttons">
|
||||
<span class="tooltip-wrapper" data-tooltip-default="Copy" data-tooltip-success="Copied">
|
||||
<button type="button" class="input-icon-button" aria-label="Copy secret key" aria-describedby="copy-tooltip" data-copy-target="secret-key">
|
||||
<?php include __DIR__ . '/../../icons/copy.svg'; ?>
|
||||
</button>
|
||||
<span id="copy-tooltip" class="tooltip typography-text-m-400 text-on-invert" role="tooltip">Copy</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="icon-button" aria-label="Regenerate secret key" data-regenerate-target="secret-key">
|
||||
<?php include __DIR__ . '/../../icons/refresh.svg'; ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
$defaultAccountEmail = $defaultAccountEmail ?? '';
|
||||
$accountEmailValue = htmlspecialchars($defaultAccountEmail, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="step-layout" data-step="3">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary">Create your account</h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
Set up the email and password for your Appwrite account. You can use these
|
||||
credentials to sign in later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-l">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="account-email" class="label-text typography-text-m-500 text-neutral-secondary">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="account-email"
|
||||
name="accountEmail"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="you@example.com"
|
||||
value="<?php echo $accountEmailValue; ?>"
|
||||
autocomplete="email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="input-group stack-xs">
|
||||
<label for="account-password"
|
||||
class="label-text typography-text-m-500 text-neutral-secondary">Password</label>
|
||||
<div class="input-action">
|
||||
<input
|
||||
type="password"
|
||||
id="account-password"
|
||||
name="accountPassword"
|
||||
class="input-action-input typography-text-m-400 text-neutral-primary"
|
||||
placeholder="Enter password"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<div class="input-action-buttons">
|
||||
<button type="button" class="input-icon-button password-toggle" aria-label="Show password" data-password-toggle="account-password">
|
||||
<span class="password-toggle-icon" data-password-icon="hide">
|
||||
<?php include __DIR__ . '/../../icons/eye-off.svg'; ?>
|
||||
</span>
|
||||
<span class="password-toggle-icon" data-password-icon="show">
|
||||
<?php include __DIR__ . '/../../icons/eye.svg'; ?>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-helper typography-caption-400 text-neutral-secondary">
|
||||
<span class="field-helper-icon">
|
||||
<?php include __DIR__ . '/../../icons/info.svg'; ?>
|
||||
</span>
|
||||
<span class="field-helper-text">Password must be at least 8 characters long</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$lockedDatabase = $lockedDatabase ?? null;
|
||||
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
|
||||
$defaultHttpPort = $defaultHttpPort ?? '80';
|
||||
$defaultHttpsPort = $defaultHttpsPort ?? '443';
|
||||
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
|
||||
$defaultSecretKey = $defaultSecretKey ?? '';
|
||||
$defaultDatabase = $defaultDatabase ?? 'mongodb';
|
||||
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
|
||||
$defaultDatabaseLabel = match ($selectedDatabase) {
|
||||
'mariadb' => 'MariaDB',
|
||||
'postgresql' => 'PostgreSQL',
|
||||
default => 'MongoDB',
|
||||
};
|
||||
$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing';
|
||||
$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
|
||||
?>
|
||||
<div class="step-layout" data-step="4">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Review your update' : 'Review your setup'; ?></h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
<?php echo $isUpgrade ? 'Confirm your settings before updating Appwrite.' : 'Check your settings below before completing the installation.'; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-xl">
|
||||
<div class="review-card">
|
||||
<div class="stack-s">
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="appDomain">
|
||||
<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Hostname</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="database">
|
||||
<?php echo htmlspecialchars((string) $defaultDatabaseLabel, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Database</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpPort">
|
||||
<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTP port</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpsPort">
|
||||
<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTPS port</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="emailCertificates">
|
||||
<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">SSL certificate email</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
|
||||
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
?>
|
||||
<div class="step-layout install-layout" data-step="5">
|
||||
<div class="install-card">
|
||||
<div class="install-panel">
|
||||
<div class="install-header">
|
||||
<div class="typography-text-m-400 text-neutral-primary">
|
||||
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-list" data-install-list></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="install-row-template">
|
||||
<div class="install-row" data-status="in-progress">
|
||||
<div class="install-row-main">
|
||||
<div class="install-row-label">
|
||||
<span class="install-icon" aria-hidden="true">
|
||||
<span class="install-icon-spinner">
|
||||
<?php include __DIR__ . '/../../icons/install-spinner.svg'; ?>
|
||||
</span>
|
||||
<span class="install-icon-check">
|
||||
<?php include __DIR__ . '/../../icons/install-check.svg'; ?>
|
||||
</span>
|
||||
<span class="install-icon-error">
|
||||
<?php include __DIR__ . '/../../icons/exclamation-circle.svg'; ?>
|
||||
</span>
|
||||
</span>
|
||||
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
|
||||
</div>
|
||||
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
|
||||
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
|
||||
</button>
|
||||
</div>
|
||||
<div class="install-row-details">
|
||||
<div class="install-row-details-inner">
|
||||
<pre class="install-error-code typography-text-xs-400" data-install-trace></pre>
|
||||
<div class="install-error-actions">
|
||||
<button type="button" class="button secondary" data-install-retry>
|
||||
<span class="button-text typography-text-m-500">Retry</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="button primary is-hidden" data-install-console>
|
||||
<span class="button-text typography-text-m-500">Navigate to Console</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
"test": "vendor/bin/phpunit",
|
||||
"lint": "vendor/bin/pint --test --config pint.json",
|
||||
"format": "vendor/bin/pint --config pint.json",
|
||||
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
|
||||
"bench": "vendor/bin/phpbench run --report=benchmark",
|
||||
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G"
|
||||
"check": "./vendor/bin/phpstan analyse -c phpstan.neon",
|
||||
"installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean",
|
||||
"installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -58,7 +61,7 @@
|
||||
"utopia-php/compression": "0.1.*",
|
||||
"utopia-php/config": "1.*",
|
||||
"utopia-php/console": "0.1.*",
|
||||
"utopia-php/database": "5.*",
|
||||
"utopia-php/database": "dev-fix-collection-recreate as 5.3.15",
|
||||
"utopia-php/detector": "0.2.*",
|
||||
"utopia-php/domains": "1.*",
|
||||
"utopia-php/emails": "0.6.*",
|
||||
@@ -91,8 +94,15 @@
|
||||
"spomky-labs/otphp": "11.*",
|
||||
"webonyx/graphql-php": "14.11.*",
|
||||
"league/csv": "9.14.*",
|
||||
"enshrined/svg-sanitize": "0.22.*"
|
||||
"enshrined/svg-sanitize": "0.22.*",
|
||||
"utopia-php/di": "0.1.0"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/utopia-php/database"
|
||||
}
|
||||
],
|
||||
"require-dev": {
|
||||
"ext-fileinfo": "*",
|
||||
"appwrite/sdk-generator": "*",
|
||||
@@ -102,8 +112,7 @@
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"textalk/websocket": "1.5.*",
|
||||
"czproject/git-php": "4.*",
|
||||
"laravel/pint": "1.*",
|
||||
"phpbench/phpbench": "1.*"
|
||||
"laravel/pint": "1.*"
|
||||
},
|
||||
"provide": {
|
||||
"ext-phpiredis": "*"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -20,10 +20,16 @@ client = Client()
|
||||
### Make Your First Request
|
||||
Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section.
|
||||
|
||||
All service methods return typed Pydantic models, so you can access response fields as attributes:
|
||||
|
||||
```python
|
||||
users = Users(client)
|
||||
|
||||
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
|
||||
print(user.name) # "Walter O'Brien"
|
||||
print(user.email) # "email@example.com"
|
||||
print(user.id) # The generated user ID
|
||||
```
|
||||
|
||||
### Full Example
|
||||
@@ -43,7 +49,60 @@ client = Client()
|
||||
|
||||
users = Users(client)
|
||||
|
||||
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
|
||||
print(user.name) # Access fields as attributes
|
||||
print(user.to_dict()) # Convert to dictionary if needed
|
||||
```
|
||||
|
||||
### Type Safety with Models
|
||||
|
||||
The Appwrite Python SDK provides type safety when working with database rows through generic methods. Methods like `get_row`, `list_rows`, and others accept a `model_type` parameter that allows you to specify your custom Pydantic model for full type safety.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
|
||||
# Define your custom model matching your table schema
|
||||
class Post(BaseModel):
|
||||
postId: int
|
||||
authorId: int
|
||||
title: str
|
||||
content: str
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
isPublished: bool
|
||||
excerpt: Optional[str] = None
|
||||
|
||||
client = Client()
|
||||
# ... configure your client ...
|
||||
|
||||
tables_db = TablesDB(client)
|
||||
|
||||
# Fetch a single row with type safety
|
||||
row = tables_db.get_row(
|
||||
database_id="your-database-id",
|
||||
table_id="your-table-id",
|
||||
row_id="your-row-id",
|
||||
model_type=Post # Pass your custom model type
|
||||
)
|
||||
|
||||
print(row.data.title) # Fully typed - IDE autocomplete works
|
||||
print(row.data.postId) # int type, not Any
|
||||
print(row.data.createdAt) # datetime type
|
||||
|
||||
# Fetch multiple rows with type safety
|
||||
result = tables_db.list_rows(
|
||||
database_id="your-database-id",
|
||||
table_id="your-table-id",
|
||||
model_type=Post
|
||||
)
|
||||
|
||||
for row in result.rows:
|
||||
print(f"{row.data.title} by {row.data.authorId}")
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
@@ -52,7 +111,8 @@ The Appwrite Python SDK raises `AppwriteException` object with `message`, `code`
|
||||
```python
|
||||
users = Users(client)
|
||||
try:
|
||||
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
print(user.name)
|
||||
except AppwriteException as e:
|
||||
print(e.message)
|
||||
```
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema":"vendor/phpbench/phpbench/phpbench.schema.json",
|
||||
"runner.bootstrap": "vendor/autoload.php",
|
||||
"runner.path": "tests",
|
||||
"runner.file_pattern": "*Bench.php"
|
||||
}
|
||||
@@ -2,7 +2,7 @@ includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
parameters:
|
||||
level: max
|
||||
level: 3
|
||||
paths:
|
||||
- src
|
||||
- app
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Runtime\State;
|
||||
use Appwrite\Platform\Installer\Server;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Complete extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerComplete';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
|
||||
class Error extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerError';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Appwrite\Auth\Validator\Password;
|
||||
use Appwrite\Platform\Installer\Runtime\Config;
|
||||
use Appwrite\Platform\Installer\Runtime\State;
|
||||
use Appwrite\Platform\Installer\Server;
|
||||
use Appwrite\Platform\Installer\Validator\AppDomain;
|
||||
use Swoole\Http\Response as SwooleResponse;
|
||||
use Utopia\Emails\Validator\Email;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Range;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
class Install extends Action
|
||||
{
|
||||
private const int SSE_KEEPALIVE_DELAY_MICROSECONDS = 500000;
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerInstall';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Swoole\Http\Server as SwooleServer;
|
||||
use Swoole\Timer;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
|
||||
class Shutdown extends Action
|
||||
{
|
||||
private const int SHUTDOWN_DELAY_SECONDS = 2;
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerShutdown';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Runtime\State;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Status extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerStatus';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Server;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
|
||||
class Validate extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerValidate';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Runtime\Config;
|
||||
use Appwrite\Platform\Installer\Server;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\Integer;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class View extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerView';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer;
|
||||
|
||||
use Utopia\Platform\Platform;
|
||||
|
||||
class Installer extends Platform
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new Module());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Services\Http;
|
||||
use Utopia\Platform;
|
||||
|
||||
class Module extends Platform\Module
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->addService('http', new Http());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Runtime;
|
||||
|
||||
final class Config
|
||||
{
|
||||
private const array KNOWN_KEYS = [
|
||||
'defaultHttpPort',
|
||||
'defaultHttpsPort',
|
||||
'organization',
|
||||
'image',
|
||||
'noStart',
|
||||
'isUpgrade',
|
||||
'isLocal',
|
||||
'hostPath',
|
||||
'lockedDatabase',
|
||||
'vars',
|
||||
];
|
||||
|
||||
private string $defaultHttpPort = '80';
|
||||
private string $defaultHttpsPort = '443';
|
||||
private string $organization = 'appwrite';
|
||||
private string $image = 'appwrite';
|
||||
private bool $noStart = false;
|
||||
private bool $isUpgrade = false;
|
||||
private bool $isLocal = false;
|
||||
private ?string $hostPath = null;
|
||||
private ?string $lockedDatabase = null;
|
||||
private array $vars = [];
|
||||
|
||||
public function __construct(array $values = [])
|
||||
{
|
||||
if (!$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Runtime;
|
||||
|
||||
use Appwrite\Platform\Installer\Server;
|
||||
|
||||
class State
|
||||
{
|
||||
private const string PATTERN_DIGITS_ONLY = '/^\d+$/';
|
||||
private const string PATTERN_HAS_NON_WHITESPACE = '/\S/';
|
||||
private const string PATTERN_LINE_BREAKS = '/\r\n|\n|\r/';
|
||||
private const string PATTERN_INSTALL_ID_SANITIZE = '/[^a-zA-Z0-9_-]/';
|
||||
private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/';
|
||||
|
||||
private const int CONFIG_FILE_PERMISSION = 0600;
|
||||
private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 3600;
|
||||
|
||||
private const int PORT_MIN = 1;
|
||||
private const int PORT_MAX = 65535;
|
||||
|
||||
private array $paths;
|
||||
private bool $bootstrapped = false;
|
||||
|
||||
public function __construct(array $paths)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer;
|
||||
|
||||
require_once __DIR__ . '/../../../../vendor/autoload.php';
|
||||
|
||||
use Appwrite\Platform\Installer\Http\Installer\Error;
|
||||
use Appwrite\Platform\Installer\Runtime\State;
|
||||
use Swoole\Http\Server as SwooleServer;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Http\Adapter\Swoole\Server as SwooleAdapter;
|
||||
use Utopia\Http\Files;
|
||||
use Utopia\Http\Http;
|
||||
use Utopia\Platform\Service;
|
||||
|
||||
class Server
|
||||
{
|
||||
public const int INSTALLER_WEB_PORT = 20080;
|
||||
public const string INSTALLER_WEB_HOST = '0.0.0.0';
|
||||
|
||||
// temp files for state and config management!
|
||||
public const string INSTALLER_LOCK_FILE = '/tmp/appwrite-install-lock.json';
|
||||
public const string INSTALLER_CONFIG_FILE = '/tmp/appwrite-installer-config.json';
|
||||
public const string INSTALLER_COMPLETE_FILE = '/tmp/appwrite-installer-complete';
|
||||
|
||||
public const string STEP_ENV_VARS = 'env-vars';
|
||||
public const string STEP_CONFIG_FILES = 'config-files';
|
||||
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
|
||||
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
|
||||
public const string STEP_ACCOUNT_SETUP = 'account-setup';
|
||||
|
||||
public const string STATUS_IN_PROGRESS = 'in-progress';
|
||||
public const string STATUS_COMPLETED = 'completed';
|
||||
public const string STATUS_ERROR = 'error';
|
||||
|
||||
public const string CSRF_COOKIE = 'appwrite-installer-csrf';
|
||||
|
||||
public const array INSTALLER_CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
];
|
||||
|
||||
private const string DEFAULT_IMAGE = 'appwrite-dev';
|
||||
public const string DEFAULT_CONTAINER = 'appwrite-installer';
|
||||
|
||||
private State $state;
|
||||
private array $paths = [];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Services;
|
||||
|
||||
use Appwrite\Platform\Installer\Http\Installer\Complete;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Install;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Status;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Validate;
|
||||
use Appwrite\Platform\Installer\Http\Installer\View;
|
||||
use Utopia\Platform\Service;
|
||||
|
||||
class Http extends Service
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Validator;
|
||||
|
||||
use Utopia\Validator;
|
||||
|
||||
/**
|
||||
* AppDomain
|
||||
*
|
||||
* Validates an app domain input: hostname, IP, localhost,
|
||||
* or IPv6 bracket notation with optional port (e.g. [::1]:8080).
|
||||
*/
|
||||
class AppDomain extends Validator
|
||||
{
|
||||
private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/';
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Value must be a valid hostname, IP address, or bracket-notation IPv6 address with optional port';
|
||||
}
|
||||
|
||||
public function isArray(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return self::TYPE_STRING;
|
||||
}
|
||||
|
||||
public function isValid($value): bool
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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 !== '') {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -531,10 +531,7 @@ class UsageTest extends Scope
|
||||
$attr = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name',
|
||||
array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id']
|
||||
], $this->getHeaders())
|
||||
$this->getConsoleHeaders()
|
||||
);
|
||||
$this->assertEquals(200, $attr['headers']['status-code']);
|
||||
$this->assertEquals('available', $attr['body']['status']);
|
||||
@@ -784,10 +781,7 @@ class UsageTest extends Scope
|
||||
$attr = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name',
|
||||
array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id']
|
||||
], $this->getHeaders())
|
||||
$this->getConsoleHeaders()
|
||||
);
|
||||
$this->assertEquals(200, $attr['headers']['status-code']);
|
||||
$this->assertEquals('available', $attr['body']['status']);
|
||||
@@ -1062,9 +1056,7 @@ class UsageTest extends Scope
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/functions/' . $functionId . '/executions/' . $executionId,
|
||||
array_merge([
|
||||
'x-appwrite-project' => $this->getProject()['$id']
|
||||
], $this->getHeaders()),
|
||||
$this->getConsoleHeaders(),
|
||||
);
|
||||
$this->assertContains($response['body']['status'], ['completed', 'failed']);
|
||||
}, 30_000, 500);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Functions\Validator;
|
||||
|
||||
use Appwrite\Functions\Validator\Headers;
|
||||
use PhpBench\Attributes\AfterMethods;
|
||||
use PhpBench\Attributes\Assert;
|
||||
use PhpBench\Attributes\BeforeMethods;
|
||||
use PhpBench\Attributes\Iterations;
|
||||
use PhpBench\Attributes\ParamProviders;
|
||||
|
||||
final class HeadersBench
|
||||
{
|
||||
private Headers $validator;
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(): void
|
||||
{
|
||||
$this->validator = new Headers();
|
||||
}
|
||||
|
||||
public function providers(): iterable
|
||||
{
|
||||
yield 'empty' => [ 'value' => [] ];
|
||||
|
||||
$value = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$value[bin2hex(random_bytes(8))] = bin2hex(random_bytes(8));
|
||||
}
|
||||
yield 'items_10-size_320' => [ 'value' => $value ];
|
||||
|
||||
$value = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$value[bin2hex(random_bytes(8))] = bin2hex(random_bytes(8));
|
||||
}
|
||||
yield 'items_100-size_3200' => [ 'value' => $value ];
|
||||
|
||||
$value = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$value[bin2hex(random_bytes(32))] = bin2hex(random_bytes(32));
|
||||
}
|
||||
yield 'items_100-size_12800' => [ 'value' => $value ];
|
||||
}
|
||||
|
||||
#[BeforeMethods('prepare')]
|
||||
#[AfterMethods('tearDown')]
|
||||
#[ParamProviders('providers')]
|
||||
#[Iterations(50)]
|
||||
#[Assert('mode(variant.time.avg) < 1 ms')]
|
||||
public function benchHeadersValidator(array $data): void
|
||||
{
|
||||
$assertion = $this->validator->isValid($data['value']);
|
||||
if (!$assertion) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Platform\Modules\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Http\Installer\Complete;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Error;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Install;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Status;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Validate;
|
||||
use Appwrite\Platform\Installer\Http\Installer\View;
|
||||
use Appwrite\Platform\Installer\Module;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Platform;
|
||||
use Utopia\Platform\Service;
|
||||
|
||||
class ModuleTest extends TestCase
|
||||
{
|
||||
protected ?Module $module = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Platform\Modules\Installer\Runtime;
|
||||
|
||||
use Appwrite\Platform\Installer\Runtime\Config;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ConfigTest extends TestCase
|
||||
{
|
||||
public function testDefaultValues(): void
|
||||
{
|
||||
$config = new Config();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Platform\Modules\Installer\Validator;
|
||||
|
||||
use Appwrite\Platform\Installer\Validator\AppDomain;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppDomainTest extends TestCase
|
||||
{
|
||||
protected ?AppDomain $validator = null;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->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:'));
|
||||
}
|
||||
}
|
||||