diff --git a/.env b/.env index 11e45517f77..3ff3672e3dc 100644 --- a/.env +++ b/.env @@ -37,6 +37,14 @@ _APP_CONSOLE_COUNTRIES_DENYLIST=AQ _APP_CONSOLE_WHITELIST_EMAILS= _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_WHITELIST_ROOT=disabled +_APP_CONSOLE_GITHUB_APP_ID= +_APP_CONSOLE_GITHUB_SECRET= +_APP_CONSOLE_GITLAB_APP_ID= +_APP_CONSOLE_GITLAB_SECRET= +_APP_CONSOLE_BITBUCKET_APP_ID= +_APP_CONSOLE_BITBUCKET_SECRET= +_APP_CONSOLE_GOOGLE_APP_ID= +_APP_CONSOLE_GOOGLE_SECRET= # MariaDB and MongoDB only start when their profile is listed here; PostgreSQL always # runs because VectorsDB requires it. MongoDB is on by default for DocumentsDB. COMPOSE_PROFILES=mongodb diff --git a/README-CN.md b/README-CN.md index e05e6e2849d..f7dd66e9c92 100644 --- a/README-CN.md +++ b/README-CN.md @@ -71,7 +71,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` ### Windows @@ -83,7 +83,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` #### PowerShell @@ -93,7 +93,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 149dbf51ae6..c9f522bf239 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` ### Windows @@ -89,7 +89,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` #### PowerShell @@ -100,7 +100,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. @@ -116,7 +116,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` Use the same `--env DOCKER_API_VERSION=...` flag with `--entrypoint="upgrade"` when upgrading. diff --git a/app/config/console.php b/app/config/console.php index 262dbc41c45..bc4c6f0b492 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -64,6 +64,9 @@ 'bitbucketEnabled' => true, 'bitbucketSecret' => System::getEnv('_APP_CONSOLE_BITBUCKET_SECRET', ''), 'bitbucketAppid' => System::getEnv('_APP_CONSOLE_BITBUCKET_APP_ID', ''), + 'googleEnabled' => true, + 'googleSecret' => System::getEnv('_APP_CONSOLE_GOOGLE_SECRET', ''), + 'googleAppid' => System::getEnv('_APP_CONSOLE_GOOGLE_APP_ID', ''), ], 'smtpBaseTemplate' => APP_BRANDED_EMAIL_BASE_TEMPLATE, ]; diff --git a/app/config/roles.php b/app/config/roles.php index 68f9160b620..fe952b59bd4 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -67,12 +67,16 @@ 'documentsdb.collections.write', 'documentsdb.documents.read', 'documentsdb.documents.write', + 'documentsdb.indexes.read', + 'documentsdb.indexes.write', 'vectorsdb.read', 'vectorsdb.write', 'vectorsdb.collections.read', 'vectorsdb.collections.write', 'vectorsdb.documents.read', 'vectorsdb.documents.write', + 'vectorsdb.indexes.read', + 'vectorsdb.indexes.write', 'tables.read', 'tables.write', 'platforms.read', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index b704d87fb1b..941e236660c 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -227,6 +227,14 @@ 'description' => 'Access to create, update, and delete document database documents', 'category' => 'Databases', ], + 'documentsdb.indexes.read' => [ + 'description' => 'Access to read document database collection indexes', + 'category' => 'Databases', + ], + 'documentsdb.indexes.write' => [ + 'description' => 'Access to create, update, and delete document database collection indexes', + 'category' => 'Databases', + ], // VectorsDB 'vectorsdb.read' => [ @@ -253,6 +261,14 @@ 'description' => 'Access to create, update, and delete vector database documents', 'category' => 'Databases', ], + 'vectorsdb.indexes.read' => [ + 'description' => 'Access to read vector database collection indexes', + 'category' => 'Databases', + ], + 'vectorsdb.indexes.write' => [ + 'description' => 'Access to create, update, and delete vector database collection indexes', + 'category' => 'Databases', + ], // Storage 'buckets.read' => [ diff --git a/app/config/variables.php b/app/config/variables.php index d286841deed..e21b086f92a 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -259,6 +259,78 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_CONSOLE_GITHUB_APP_ID', + 'description' => 'GitHub OAuth app client ID used for signing in to the Appwrite console. You can find it in your GitHub OAuth application details. This is separate from _APP_VCS_GITHUB_APP_ID, which powers repository integration rather than console sign-in and holds the numeric GitHub App ID instead of an OAuth client ID.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_GITHUB_SECRET', + 'description' => 'GitHub OAuth app client secret used for signing in to the Appwrite console. You can generate secrets in your GitHub OAuth application settings. This is separate from _APP_VCS_GITHUB_CLIENT_SECRET, which powers repository integration rather than console sign-in.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_GITLAB_APP_ID', + 'description' => 'GitLab OAuth application ID used for signing in to the Appwrite console. You can find it in your GitLab application details. This is separate from _APP_VCS_GITLAB_CLIENT_ID, which powers repository integration rather than console sign-in.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_GITLAB_SECRET', + 'description' => 'GitLab OAuth application secret used for signing in to the Appwrite console. You can generate one in your GitLab application settings. This is separate from _APP_VCS_GITLAB_CLIENT_SECRET, which powers repository integration rather than console sign-in.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_BITBUCKET_APP_ID', + 'description' => 'Bitbucket OAuth consumer key used for signing in to the Appwrite console. You can find it in your Bitbucket workspace settings under OAuth consumers. This is separate from _APP_VCS_BITBUCKET_CLIENT_ID, which powers repository integration rather than console sign-in.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_BITBUCKET_SECRET', + 'description' => 'Bitbucket OAuth consumer secret used for signing in to the Appwrite console. You can find it alongside the consumer key in your Bitbucket workspace settings under OAuth consumers. This is separate from _APP_VCS_BITBUCKET_CLIENT_SECRET, which powers repository integration rather than console sign-in.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_GOOGLE_APP_ID', + 'description' => 'Google OAuth 2.0 client ID used for signing in to the Appwrite console. You can create one in the Google Cloud Console under APIs & Services > Credentials. Unlike the other console sign-in providers, Google has no _APP_VCS_ counterpart, as it is not a repository host.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_GOOGLE_SECRET', + 'description' => 'Google OAuth 2.0 client secret used for signing in to the Appwrite console. You can find it alongside the client ID in the Google Cloud Console under APIs & Services > Credentials.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_SYSTEM_EMAIL_NAME', 'description' => 'This is the sender name value that will appear on email messages sent to developers from the Appwrite console. The default value is: \'Appwrite\'. You can use url encoded strings for spaces and special chars.', @@ -351,7 +423,7 @@ ], [ 'name' => '_APP_WORKER_PER_CORE', - 'description' => 'Internal Worker per core for the API, Realtime and Executor containers. Can be configured to optimize performance.', + 'description' => 'Internal Worker per core for the API and Executor containers. Can be configured to optimize performance. Realtime ignores this and runs a single worker per container; use _APP_WORKERS_NUM to override.', 'introduction' => '0.13.0', 'default' => 6, 'required' => false, diff --git a/app/init/constants.php b/app/init/constants.php index 0bd3193500f..f6e0abae849 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -99,8 +99,8 @@ const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4326; -const APP_VERSION_STABLE = '1.9.6'; +const APP_CACHE_BUSTER = 4327; +const APP_VERSION_STABLE = '2.0.0'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/realtime.php b/app/realtime.php index 2c5894bdf24..1c759eb3b57 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -393,8 +393,13 @@ function checkForProjectUsage(Document $project): void $containerId = uniqid(); $statsDocument = null; -$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) - ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); +// Realtime is I/O bound: a single worker holding ~1200 websocket connections +// measured 0.06-0.35 cores, so extra workers add forked interpreter copies (~84% +// of a worker's footprint is fixed overhead, not connection state), duplicate the +// firehose subscription so every worker json_decodes every event, and split the +// accept distribution into a second, invisible balancing layer. Concurrency is the +// deployment's job. `_APP_WORKERS_NUM` still overrides. +$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) ?: 1; $adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80)); $adapter @@ -545,6 +550,10 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); + // Fan-out cost is driven by bytes, not message count: one large document to many + // subscribers allocates far more than many small ones. Without this, a burst that + // moves hundreds of MB is invisible next to a flat message rate. + $register->set('telemetry.outboundBytesCounter', fn () => $telemetry->createCounter('realtime.server.outbound_bytes', 'By')); $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( name: 'realtime.server.delivery_delay', unit: 'ms', @@ -639,18 +648,11 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $subscribers = $realtime->getSubscribers($event); - $groups = []; foreach ($subscribers as $id => $matched) { - $key = implode(',', array_keys($matched)); - $groups[$key]['ids'][] = $id; - $groups[$key]['subscriptions'] = array_keys($matched); - } - - foreach ($groups as $group) { $data = $event['data']; - $data['subscriptions'] = $group['subscriptions']; + $data['subscriptions'] = array_keys($matched); - $server->send($group['ids'], json_encode([ + $server->send([$id], json_encode([ 'type' => 'event', 'data' => $data ])); @@ -788,35 +790,36 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document Console::log("[Debug][Worker {$workerId}] Event: " . $payload); } - // Group connections by matched subscription IDs for batch sending - $groups = []; - foreach ($receivers as $id => $matched) { - $key = implode(',', array_keys($matched)); - $groups[$key]['ids'][] = $id; - $groups[$key]['subscriptions'] = array_keys($matched); - } - $total = 0; $outboundBytes = 0; - foreach ($groups as $group) { - $data = $event['data']; - $data['subscriptions'] = $group['subscriptions']; + // One frame per connection: `subscriptions` carries that connection's + // matched subscription IDs, and those are ID::unique() per connection + // (see the subscribe handler), so no two connections can ever share a + // frame. (The grouping this replaced keyed on exactly those IDs and so + // never collapsed -- it always built one group of one.) + // + // `subscriptions` is the only part that varies, and it is small, so the + // document is serialised once per event rather than once per subscriber. + // This loop's json_encode was 26% of realtime's on-CPU work during a + // fan-out burst, against 2.8% at rest. + $data = $event['data']; + unset($data['subscriptions']); + $tail = $data === [] ? '' : ',' . substr(json_encode($data), 1, -1); - $payloadJson = json_encode([ - 'type' => 'event', - 'data' => $data - ]); + foreach ($receivers as $id => $matched) { + $payloadJson = '{"type":"event","data":{"subscriptions":' + . json_encode(array_keys($matched)) . $tail . '}}'; - $server->send($group['ids'], $payloadJson); + $server->send([$id], $payloadJson); - $count = count($group['ids']); - $total += $count; - $outboundBytes += strlen($payloadJson) * $count; + $total++; + $outboundBytes += strlen($payloadJson); } if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); + $register->get('telemetry.outboundBytesCounter')->add($outboundBytes); $stats->incr($event['project'], 'messages', $total); $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; if (\is_string($updatedAt)) { @@ -836,15 +839,12 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $projectId = $event['project'] ?? null; if (!empty($projectId)) { - $metrics = [ + // Reached only when $total > 0, and every frame carries the + // literal envelope, so outbound bytes are always non-zero. + triggerStats([ METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, - ]; - - if ($outboundBytes > 0) { - $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; - } - - triggerStats($metrics, $projectId); + METRIC_REALTIME_OUTBOUND => $outboundBytes, + ], $projectId); } } @@ -1418,7 +1418,7 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes')); } - /** @var array $presencesById */ + /** @var array $presencesById set of presence ids owned by this connection */ $presencesById = $realtime->connections[$connection]['presences'] ?? []; if ( @@ -1441,7 +1441,6 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document } $presenceIds = \array_keys($presencesById); - $presences = \array_values($presencesById); $dbForProject = getProjectDB($project); $user = new User([]); @@ -1461,16 +1460,16 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document /** @var UsagePublisher $publisherForUsage */ $publisherForUsage = $container->get('publisherForUsage'); - /** @var array $deletedIds */ - $deletedIds = []; + /** @var array $deletedPresences */ + $deletedPresences = []; try { $deletionCount = $dbForProject->getAuthorization()->skip( - function () use ($dbForProject, $presenceIds, &$deletedIds): int { + function () use ($dbForProject, $presenceIds, &$deletedPresences): int { return $dbForProject->deleteDocuments( 'presenceLogs', [Query::equal('$id', $presenceIds)], - onNext: function (Document $deleted) use (&$deletedIds): void { - $deletedIds[$deleted->getId()] = true; + onNext: function (Document $deleted) use (&$deletedPresences): void { + $deletedPresences[$deleted->getId()] = $deleted; }, ); } @@ -1480,16 +1479,18 @@ function () use ($dbForProject, $presenceIds, &$deletedIds): int { Span::current()?->setError($th); logError($th, 'realtimeOnClosePresenceDeletion', tags: [ 'projectId' => $projectId, - 'presences' => \count($presences) + 'presences' => \count($presenceIds) ]); } $queueForEvents = getQueueForEvents(); $queueForRealtime = getQueueForRealtime(); - foreach ($presences as $presence) { - if (!isset($deletedIds[$presence->getId()])) { - continue; - } + foreach ($deletedPresences as $presence) { + $presence->removeAttribute('$collection'); + $presence->removeAttribute('$tenant'); + $presence->removeAttribute('hostname'); + $presence->removeAttribute('permissionsHash'); + $presence->removeAttribute('userInternalId'); try { $presenceState->triggerEvent( $queueForEvents, diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index e6f9e87b8d9..875d4f669fb 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -476,6 +476,13 @@ body { min-height: var(--step-min-height, auto); flex: 1 1 auto; overflow: hidden; + transition: min-height var(--duration-medium) var(--ease-standard); +} + +@media (prefers-reduced-motion: reduce) { + .installer-step { + transition: none; + } } .installer-page[data-upgrade='true'] .installer-step { @@ -541,6 +548,11 @@ body { .step-panel.is-exiting { opacity: 0; pointer-events: none; + position: absolute; + top: 0; + left: 0; + height: auto; + overflow: hidden; } .step-panel.is-measure { diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js index 07ec7bb1efd..cd33af642f0 100644 --- a/app/views/install/installer/js/installer.js +++ b/app/views/install/installer/js/installer.js @@ -48,7 +48,7 @@ const STEP_CONFIG = buildStepConfig(); const stepCache = new Map(); - let maxStepHeight = 0; + const stepHeights = new Map(); let isTransitioning = false; let pendingStep = null; let pendingPushState = false; @@ -183,12 +183,25 @@ } }; - const measureStepHeight = (panel) => { - if (!panel) return; + // Each step is remembered on its own rather than folded into a running maximum. A + // single tall step used to set the floor for every other one, so the short ones -- the + // review in particular -- carried its leftover height as dead space. + const recordStepHeight = (panel, step) => { + if (!panel || step == null) return; const height = panel.getBoundingClientRect().height; if (!height) return; - maxStepHeight = Math.max(maxStepHeight, height); - stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`); + stepHeights.set(Number(step), height); + }; + + const applyStepHeight = (step) => { + const height = stepHeights.get(Number(step)); + if (!height) return; + stepContainer.style.setProperty('--step-min-height', `${height}px`); + }; + + const measureStepHeight = (panel, step) => { + recordStepHeight(panel, step); + applyStepHeight(step); }; const runStepInit = (step, rootElement) => { @@ -237,7 +250,7 @@ panel.innerHTML = html; stepContainer.appendChild(panel); panel.getBoundingClientRect(); - measureStepHeight(panel); + recordStepHeight(panel, step); panel.remove(); }) .catch(() => null); @@ -253,7 +266,7 @@ measurePanel.innerHTML = html; stepContainer.appendChild(measurePanel); measurePanel.getBoundingClientRect(); - measureStepHeight(measurePanel); + recordStepHeight(measurePanel, step); measurePanel.remove(); const newPanel = document.createElement('div'); @@ -265,6 +278,9 @@ newPanel.getBoundingClientRect(); requestAnimationFrame(() => { + // Height and opacity start together, so the card resizes as the step fades + // rather than reflowing the outgoing one first. + applyStepHeight(step); newPanel.classList.remove('is-entering'); newPanel.classList.add('is-active'); if (activePanel) { @@ -440,12 +456,12 @@ } const activePanel = stepContainer.querySelector('.step-panel') || stepContainer; runStepInit(step, activePanel); - measureStepHeight(activePanel); + measureStepHeight(activePanel, step); if (step === 5 && installScreen) { runStepInit(step, installScreen); } const preload = () => { - measureStepHeight(activePanel); + measureStepHeight(activePanel, step); preloadSteps(cardSteps); }; if (document.fonts && document.fonts.ready) { diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index c1e59add0e4..c87e0f956e7 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -374,6 +374,7 @@ forceHttps: formState?.forceHttps === true, opensslKey: (formState?.opensslKey || '').trim(), assistantOpenAIKey: normalizedAssistantKey, + accountName: (formState?.accountName || '').trim(), accountEmail: normalizedAccountEmail, accountPassword: normalizedAccountPassword, migrate: formState?.migrate ?? false diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index 408c3d28e9b..4639ebdfda8 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -19,7 +19,8 @@ forceHttps: null, opensslKey: null, assistantOpenAIKey: null, - topology: 'combined', + topology: null, + accountName: null, accountEmail: null, accountPassword: null }; @@ -53,7 +54,7 @@ formState.database = data.lockedDatabase; } if (data.topology === 'combined' || data.topology === 'separate') { - formState.topology = data.topology; + setStateIfEmpty('topology', data.topology); } if (!isUpgradeMode?.()) { setStateIfEmpty('database', data.defaultDatabase); diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js index 1ef47f69d8f..507fab89820 100644 --- a/app/views/install/installer/js/modules/ui.js +++ b/app/views/install/installer/js/modules/ui.js @@ -241,14 +241,24 @@ if (key === 'database') { value = toDatabaseLabel(formState?.database); } - if (key === 'emailCertificates' && !value) { - value = formState?.accountEmail; - } if (value) { node.textContent = value; } }); + // Nothing entered and no account email to borrow: shown as a tag, the way the + // other absent settings on this panel are, rather than an empty row. + const emailNode = root.querySelector('[data-review-value="emailCertificates"]'); + if (emailNode) { + const email = (formState?.emailCertificates || formState?.accountEmail || '').trim(); + emailNode.textContent = email || 'Empty'; + emailNode.classList.toggle('badge', !email); + emailNode.classList.toggle('badge-neutral', !email); + emailNode.classList.toggle('typography-text-xs-400', !email); + emailNode.classList.toggle('typography-text-m-500', Boolean(email)); + emailNode.classList.toggle('text-neutral-primary', Boolean(email)); + } + const badge = root.querySelector('[data-review-badge]'); if (badge) { const hasKey = Boolean((formState?.opensslKey || '').trim()); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 00176bce5c2..a26b0ac7a6f 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -114,6 +114,22 @@ }); }; + // A hostname that no public certificate authority will issue for: loopback names, + // .local/.internal names, and bare IP literals. Those are served over plain HTTP, so + // the toggle follows the hostname instead of making the operator know this. + const servedOverPlainHttp = (value) => { + const host = (value || '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + + if (host === '') return true; + if (host === 'localhost' || host.endsWith('.localhost')) return true; + if (host.endsWith('.local') || host.endsWith('.internal')) return true; + if (host === '::1' || host === '0.0.0.0') return true; + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; + if (host.includes(':') && /^[0-9a-f:]+$/.test(host)) return true; + + return false; + }; + const hydrateStep1State = (root) => { State.setStateIfEmpty?.('appDomain', root.querySelector('#hostname')?.value); State.setStateIfEmpty?.('database', root.querySelector('input[name="database"]:checked')?.value); @@ -208,6 +224,24 @@ const assistantKey = root.querySelector('#assistant-openai-key'); bindInputToState(hostname, 'appDomain'); + + // Follow the hostname until the operator sets the toggle themselves, after which + // their choice stands however the hostname changes. + if (hostname && forceHttps) { + const followHostname = () => { + if (forceHttps.dataset.touched === 'true') return; + const https = !servedOverPlainHttp(hostname.value); + forceHttps.checked = https; + formState.forceHttps = https; + }; + + forceHttps.addEventListener('change', () => { + forceHttps.dataset.touched = 'true'; + }); + hostname.addEventListener('input', followHostname); + followHostname(); + } + bindInputToState(httpPort, 'httpPort'); bindInputToState(httpsPort, 'httpsPort'); bindInputToState(sslEmail, 'emailCertificates'); @@ -310,11 +344,15 @@ }; const hydrateStep3State = (root) => { + State.setStateIfEmpty?.('accountName', root.querySelector('#account-name')?.value); State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value); State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value); }; const applyStep3State = (root) => { + const accountName = root.querySelector('#account-name'); + if (accountName && formState.accountName) accountName.value = formState.accountName; + const email = root.querySelector('#account-email'); if (email && formState.accountEmail) email.value = formState.accountEmail; @@ -330,10 +368,12 @@ hydrateStep3State(root); applyStep3State(root); + const accountName = root.querySelector('#account-name'); const email = root.querySelector('#account-email'); const password = root.querySelector('#account-password'); const passwordToggle = root.querySelector('[data-password-toggle="account-password"]'); + bindInputToState(accountName, 'accountName'); bindInputToState(email, 'accountEmail'); bindInputToState(password, 'accountPassword'); @@ -486,17 +526,25 @@ let valid = true; const email = root?.querySelector('#account-email'); const password = root?.querySelector('#account-password'); + const emailValue = email?.value.trim() ?? ''; + const passwordValue = password?.value ?? ''; + + // The account is optional -- the installer skips creating one when either + // field is blank, and it can be created from the console afterwards. Half + // an account is still an error, since that reads as an attempt to make one. + if (emailValue === '' && passwordValue === '') { + return true; + } - if (!email || !email.value.trim()) { + if (emailValue === '') { setFieldError?.(email, 'This field is required'); valid = false; - } else if (!isValidEmail?.(email.value.trim())) { + } else if (!isValidEmail?.(emailValue)) { setFieldError?.(email, 'Please enter a valid email address'); valid = false; } - const passwordValue = password?.value ?? ''; - if (!password || !/\S/.test(passwordValue)) { + if (!/\S/.test(passwordValue)) { setFieldError?.(password, 'This field is required'); valid = false; } else if (!isValidPassword?.(passwordValue)) { diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml index f157dad933f..eb8ca6d8638 100644 --- a/app/views/install/installer/templates/steps/step-1.phtml +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -36,7 +36,7 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey,
- +
@@ -61,7 +66,7 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey,
- +