Merge origin/1.8.x into feat-user-impersonation

This commit is contained in:
eldadfux
2026-03-18 10:23:03 +01:00
89 changed files with 10557 additions and 82523 deletions
@@ -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;
}
}
+316
View File
@@ -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
}
}
}
}
File diff suppressed because it is too large Load Diff
+46 -9
View File
@@ -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
);
}
}
+1 -1
View File
@@ -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', '');
+2 -1
View File
@@ -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;
}
+1 -3
View File
@@ -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,
};
}