mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge origin/1.8.x into feat-user-impersonation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user