diff --git a/.gitignore b/.gitignore index 1d0f0533f2..bad56a804c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,16 @@ dev/yasd_init.php .phpunit.result.cache Makefile appwrite.config.json -/.zed/ \ No newline at end of file +/.zed/ +playwright-report +test-results + +# Web installer test files +docker-compose.web-installer.yml +.env.web-installer + +# backups when testing upgrade mode. +docker-compose.web-installer.yml.**.backup + +# screenshots +tests/playwright/screenshots \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e848b6f0b5..2ae3bee045 100755 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,8 @@ COPY ./app /usr/src/code/app COPY ./public /usr/src/code/public COPY ./bin /usr/local/bin COPY ./src /usr/src/code/src +COPY ./dev /usr/src/code/dev +COPY ./mongo-entrypoint.sh /usr/src/code/mongo-entrypoint.sh # Set Volumes RUN mkdir -p /storage/uploads && \ @@ -116,5 +118,6 @@ RUN if [ "$DEBUG" = "true" ]; then \ fi EXPOSE 80 +EXPOSE 8080 CMD [ "php", "app/http.php" ] diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 16af33ca3a..aa3905efab 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -12,6 +12,8 @@ $version = $this->getParam('version', ''); $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); +$dbService = $this->getParam('database'); +$hostPath = rtrim($this->getParam('hostPath', ''), '/'); ?>services: traefik: image: traefik:2.11 @@ -61,6 +63,9 @@ $enableAssistant = $this->getParam('enableAssistant', false); - traefik.http.routers.appwrite_api_https.service=appwrite_api - traefik.http.routers.appwrite_api_https.tls=true volumes: + + - :/usr/src/code:rw + - appwrite-uploads:/storage/uploads:rw - appwrite-imports:/storage/imports:rw - appwrite-cache:/storage/cache:rw @@ -70,7 +75,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw depends_on: - - mariadb + - - redis # - clamav environment: @@ -223,7 +228,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -253,7 +258,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -279,7 +284,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -307,7 +312,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - volumes: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw @@ -370,7 +375,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -396,7 +401,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - volumes: - appwrite-functions:/storage/functions:rw - appwrite-sites:/storage/sites:rw @@ -464,7 +469,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - volumes: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw @@ -501,7 +506,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - - openruntimes-executor environment: - _APP_ENV @@ -626,7 +631,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); volumes: - appwrite-imports:/storage/imports:rw depends_on: - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -700,7 +705,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -729,7 +734,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -757,7 +762,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite depends_on: - redis - - mariadb + - environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -784,7 +789,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -809,7 +814,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -834,7 +839,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); networks: - appwrite depends_on: - - mariadb + - - redis environment: - _APP_ENV @@ -922,6 +927,7 @@ $enableAssistant = $this->getParam('enableAssistant', false); - OPR_EXECUTOR_STORAGE_WASABI_REGION=$_APP_STORAGE_WASABI_REGION - OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET + mariadb: image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p container_name: appwrite-mariadb @@ -931,6 +937,8 @@ $enableAssistant = $this->getParam('enableAssistant', false); - appwrite volumes: - appwrite-mariadb:/var/lib/mysql:rw + ports: + - "3306:3306" environment: - MYSQL_ROOT_PASSWORD=${_APP_DB_ROOT_PASS} - MYSQL_DATABASE=${_APP_DB_SCHEMA} @@ -938,6 +946,28 @@ $enableAssistant = $this->getParam('enableAssistant', false); - MYSQL_PASSWORD=${_APP_DB_PASS} - MARIADB_AUTO_UPGRADE=1 command: 'mysqld --innodb-flush-method=fsync' + + mongodb: + image: mongo:8.0.8 + container_name: appwrite-mongodb + <<: *x-logging + restart: unless-stopped + networks: + - appwrite + volumes: + - appwrite-mongodb:/data/db:rw + - appwrite-mongodb-config:/data/configdb:rw + + - /mongo-entrypoint.sh:/mongo-entrypoint.sh:ro + + - ./mongo-entrypoint.sh:/mongo-entrypoint.sh:ro + + ports: + - "27017:27017" + environment: + - MONGO_INITDB_DATABASE=${_APP_DB_SCHEMA} + entrypoint: ["/bin/bash", "/mongo-entrypoint.sh"] + redis: image: redis:7.2.4-alpine @@ -972,7 +1002,12 @@ networks: name: runtimes volumes: + appwrite-mariadb: + + appwrite-mongodb: + appwrite-mongodb-config: + appwrite-redis: appwrite-cache: appwrite-uploads: diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml new file mode 100644 index 0000000000..9d5a3a0d39 --- /dev/null +++ b/app/views/install/installer.phtml @@ -0,0 +1,162 @@ + + + + + + + <?php echo $isUpgrade ? 'Appwrite Update' : 'Appwrite Installation'; ?> + + + + + + + + + + + + + + + + + data-locked-database="" + + data-default-http-port="" + data-default-https-port="" + data-default-app-domain="" + data-default-email-certificates="" + data-default-secret-key="" + data-default-assistant-openai-key="" + data-default-database="" + + data-dev-mode="true" + +> + + +
+
+
+
+ +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + +
+ + diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css new file mode 100644 index 0000000000..4601cfcb67 --- /dev/null +++ b/app/views/install/installer/css/styles.css @@ -0,0 +1,1743 @@ +/* Installer Styles - Pink v2 tokens and components */ + +:root { + /* Neutral colors */ + --neutral-0: rgba(255, 255, 255, 1); + --neutral-25: rgba(250, 250, 251, 1); + --neutral-40: rgba(244, 244, 247, 1); + --neutral-50: rgba(237, 237, 240, 1); + --neutral-100: rgba(228, 228, 231, 1); + --neutral-200: rgba(216, 216, 219, 1); + --neutral-300: rgba(173, 173, 176, 1); + --neutral-400: rgba(151, 151, 155, 1); + --neutral-500: rgba(129, 129, 134, 1); + --neutral-700: rgba(86, 86, 92, 1); + --neutral-800: rgba(45, 45, 49, 1); + --neutral-900: rgba(25, 25, 28, 1); + + /* Warning colors */ + --web-orange-200: rgba(255, 213, 194, 1); + --web-orange-500: rgba(254, 124, 67, 1); + --web-orange-700: rgba(97, 37, 10, 1); + + /* Error colors */ + --web-red-500: rgba(255, 69, 58, 1); + --web-red-700: rgba(179, 18, 18, 1); + + /* Brand colors */ + --brand-pink-500: rgba(253, 54, 110, 1); + --brand-pink-600: rgba(202, 43, 88, 1); + --brand-pink-700: rgba(152, 32, 66, 1); + + /* Background colors */ + --bgcolor-neutral-default: var(--neutral-25); + --bgcolor-neutral-primary: var(--neutral-0); + --bgcolor-neutral-secondary: var(--neutral-40); + --bgcolor-neutral-tertiary: var(--neutral-50); + --bgcolor-neutral-invert-weaker: var(--neutral-500); + --bgcolor-neutral-invert-weak: var(--neutral-700); + --bgcolor-accent: var(--brand-pink-500); + --bgcolor-accent-secondary: var(--brand-pink-600); + --bgcolor-accent-tertiary: var(--brand-pink-700); + --bgcolor-success-weak: rgba(16, 185, 129, 0.16); + --bgcolor-warning-weaker: rgba(254, 124, 67, 0.04); + --bgcolor-warning-weak: rgba(254, 124, 67, 0.16); + --bgcolor-error: var(--web-red-500); + --bgcolor-error-weaker: rgba(255, 69, 58, 0.04); + + /* Foreground colors */ + --fgcolor-neutral-primary: var(--neutral-800); + --fgcolor-neutral-secondary: var(--neutral-700); + --fgcolor-neutral-tertiary: var(--neutral-400); + --fgcolor-neutral-weak: var(--neutral-200); + --fgcolor-accent: var(--brand-pink-500); + --fgcolor-on-accent: var(--neutral-0); + --fgcolor-on-invert: var(--neutral-25); + --fgcolor-on-success-weak: rgba(10, 113, 79, 1); + --fgcolor-warning: rgba(97, 37, 10, 1); + --fgcolor-on-warning-weak: var(--web-orange-700); + --fgcolor-error: var(--web-red-700); + --fgcolor-on-error: var(--neutral-0); + + /* Border colors */ + --border-neutral: var(--neutral-50); + --border-neutral-strong: var(--neutral-200); + --border-neutral-stronger: var(--neutral-500); + --border-focus: var(--neutral-300); + --border-accent: var(--brand-pink-500); + --border-warning-weak: rgba(254, 124, 67, 0.32); + --border-error: var(--web-red-500); + --border-error-weak: rgba(255, 69, 58, 0.32); + + /* Overlay colors */ + --overlay-neutral-hover: rgba(25, 25, 28, 0.03); + --overlay-neutral-pressed: rgba(25, 25, 28, 0.04); + --overlay-scrim: rgba(25, 25, 28, 0.8); + + /* Icon sizes */ + --icon-size-s: var(--base-16); + + /* Typography */ + --font-family-brand: 'Aeonik Pro'; + --font-family-sansserif: 'Inter'; + --font-family-code: 'Fira Code'; + --sans-fallbacks: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --mono-fallbacks: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + + --font-size-xs: 12px; + --font-size-s: 14px; + --font-size-m: 16px; + --font-size-l: 20px; + --label-line-height: 19.6px; + + /* Motion */ + --duration-fast: 150ms; + --duration-short: 160ms; + --duration-medium: 200ms; + --duration-extended: 250ms; + --duration-slow: 300ms; + --ease-standard: ease; + --ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1); + --ease-in-out: ease-in-out; + --ease-out: ease-out; + + /* Spacing */ + --base-0: 0; + --base-2: 2px; + --base-4: 4px; + --base-6: 6px; + --base-8: 8px; + --base-10: 10px; + --base-12: 12px; + --base-16: 16px; + --base-20: 20px; + --base-24: 24px; + --base-32: 32px; + --base-40: 40px; + --base-48: 48px; + + /* Component sizing */ + --button-min-width-s: 60px; + + --space-0: var(--base-0); + --space-1: var(--base-2); + --space-2: var(--base-4); + --space-3: var(--base-6); + --space-4: var(--base-8); + --space-5: var(--base-10); + --space-6: var(--base-12); + --space-7: var(--base-16); + --space-8: var(--base-20); + --space-9: var(--base-24); + --space-10: var(--base-32); + --space-11: var(--base-40); + --space-12: var(--base-48); + + /* Gap scale (Pink tokens) */ + --gap-none: var(--base-0); + --gap-xxxs: var(--base-2); + --gap-xxs: var(--base-4); + --gap-xs: var(--base-6); + --gap-s: var(--base-8); + --gap-m: var(--base-12); + --gap-l: var(--base-16); + --gap-xl: var(--base-20); + --gap-xxl: var(--base-32); + --gap-xxxl: var(--base-40); + + /* Border radius */ + --border-radius-s: 8px; + --border-radius-xs: 6px; + --border-radius-m: 12px; + --border-radius-l: 16px; + + /* Border widths */ + --border-width-s: 1px; + --border-width-l: 2px; + + /* Installer layout vars */ + --step-min-height: auto; + --divider-gap-top: var(--gap-xl); + + /* Animation vars */ + --spinner-rotation: 0deg; + + /* Legacy aliases */ + --bgColor-neutral-default: var(--bgcolor-neutral-default); + --bgColor-neutral-primary: var(--bgcolor-neutral-primary); + --bgColor-accent: var(--bgcolor-accent); + --fgColor-neutral-primary: var(--fgcolor-neutral-primary); + --fgColor-neutral-secondary: var(--fgcolor-neutral-secondary); + --fgColor-neutral-tertiary: var(--fgcolor-neutral-tertiary); + --fgColor-neutral-weak: var(--fgcolor-neutral-weak); + --fgColor-accent: var(--fgcolor-accent); + --fgColor-on-accent: var(--fgcolor-on-accent); +} + +.installer-toast-stack { + position: fixed; + top: var(--base-12); + right: var(--base-12); + display: flex; + flex-direction: column; + gap: var(--space-4); + z-index: 60; + pointer-events: none; +} + +.installer-toast { + inline-size: 24rem; + display: inline-flex; + align-items: start; + justify-content: space-between; + gap: var(--space-6); + padding: var(--space-4); + border-radius: var(--border-radius-m); + border: var(--border-width-s) solid var(--border-neutral); + background: var(--bgcolor-neutral-primary); + box-shadow: + 0 2px 12px 0 rgba(0, 0, 0, 0.02), + 0 6px 8px 0 rgba(0, 0, 0, 0.02); + pointer-events: auto; + opacity: 1; + transform: translate3d(0, 0, 0); + will-change: transform, opacity; + transition: + transform 400ms cubic-bezier(0.33, 1, 0.68, 1), + opacity 400ms cubic-bezier(0.33, 1, 0.68, 1); +} + +.installer-toast.is-entering { + opacity: 0; + transform: translate3d(50px, 0, 0); +} + +.installer-toast.is-leaving { + opacity: 0; + transform: translate3d(50px, 0, 0); + pointer-events: none; +} + +.installer-toast-content { + display: flex; + align-items: start; + gap: var(--space-6); +} + +.installer-toast-body { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-block: auto; +} + +.installer-toast-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-s); + background: var(--bgcolor-neutral-invert-weaker); + color: var(--fgcolor-on-invert); + flex-shrink: 0; +} + +.installer-toast-icon svg { + width: 16px; + height: 16px; + display: block; +} + +.installer-toast-icon[data-status='error'] { + background: var(--bgcolor-error); + color: var(--fgcolor-on-error); +} + +.installer-toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + border: var(--border-width-s) solid transparent; + border-radius: var(--border-radius-s); + background: transparent; + color: var(--fgcolor-neutral-tertiary); + cursor: pointer; + transition: all 0.15s ease-in-out; +} + +.installer-toast-close svg { + width: 16px; + height: 16px; + display: block; +} + +.installer-toast-close:hover { + color: var(--fgcolor-neutral-secondary); + background: var(--overlay-neutral-hover); +} + +.installer-toast-close:active { + color: var(--fgcolor-neutral-secondary); + background: var(--overlay-neutral-pressed); +} + +.installer-toast-close:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); + outline-offset: var(--border-width-s); +} + +.is-hidden { + display: none !important; +} + +@media (min-width: 768px) { + .installer-toast-stack { + top: var(--base-24); + right: var(--base-24); + } +} + +@media (max-width: 640px) { + .installer-toast-stack { + left: 0; + right: 0; + top: var(--base-12); + padding: 0 var(--space-4); + align-items: stretch; + } + + .installer-toast { + inline-size: 100%; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--fgcolor-neutral-primary); + background: var(--bgcolor-neutral-default); + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; +} + +.installer-page { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-7); + gap: var(--gap-l); + background: var(--bgcolor-neutral-default); + position: relative; + overflow: hidden; +} + +.installer-main { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; + min-height: 0; +} + +.installer-backdrop { + position: fixed; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-medium) var(--ease-standard); + z-index: 0; +} + +.installer-page[data-step='5'] .installer-backdrop { + opacity: 1; +} + +.installer-gradients { + top: 34%; + left: 17%; + width: 409px; + height: 194px; + opacity: 0.32; + position: absolute; + pointer-events: none; + transform: translateY(-50%) rotate(-25deg); +} + +.installer-blob { + position: absolute; + left: 0; + top: 0; + --blob-x: 0; + --blob-y: 0; + transform: translate(var(--blob-x), var(--blob-y)) scale(1); + transform-origin: center; + animation: none; +} + +.installer-blob.blob-one { + --blob-x: 268.3px; + --blob-y: 108.3px; +} + +.installer-blob.blob-two { + --blob-x: 101.3px; + --blob-y: 101.3px; +} + +.installer-card { + width: 100%; + max-width: 500px; + max-height: 100%; + padding: var(--space-8); + background: var(--bgcolor-neutral-primary); + border-radius: var(--border-radius-l); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: -1px; + display: flex; + flex-direction: column; + gap: 0; + transition: opacity var(--duration-medium) var(--ease-standard); + overflow: hidden; +} + +.installer-page[data-step='5'] .installer-card { + opacity: 0; + pointer-events: none; +} + +.installer-page[data-install-locked='true'] .step-indicators { + opacity: 0.4; +} + +.selector-card.is-disabled { + cursor: default; + pointer-events: auto; +} + +.selector-card.is-disabled .selector-content, +.selector-card.is-disabled .selector-icon { + opacity: 0.4; +} + +.installer-step { + display: grid; + position: relative; + width: 100%; + min-height: var(--step-min-height, auto); + flex: 1 1 auto; + overflow: hidden; +} + +.action-shell { + display: flex; + flex-direction: column; + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +.install-screen { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-medium) var(--ease-standard); + z-index: 1; +} + +.installer-page[data-step='5'] .install-screen { + opacity: 1; + pointer-events: auto; +} + +.install-screen-content { + width: 100%; + max-width: 500px; +} + +.step-panel { + grid-area: 1 / 1; + width: 100%; + opacity: 1; + transition: opacity var(--duration-medium) var(--ease-standard); + will-change: opacity; +} + +.step-panel:not(.is-measure) { + height: 100%; + overflow-y: auto; + scrollbar-gutter: stable; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.step-panel:not(.is-measure)::-webkit-scrollbar { + width: 0; + height: 0; +} + +.step-panel.is-entering { + opacity: 0; + pointer-events: none; +} + +.step-panel.is-exiting { + opacity: 0; + pointer-events: none; +} + +.step-panel.is-measure { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.stack-none { + display: flex; + flex-direction: column; + gap: var(--gap-none); +} + +.stack-xxxs { + display: flex; + flex-direction: column; + gap: var(--gap-xxxs); +} + +.stack-xxs { + display: flex; + flex-direction: column; + gap: var(--gap-xxs); +} + +.stack-xs { + display: flex; + flex-direction: column; + gap: var(--gap-xs); +} + +.stack-s { + display: flex; + flex-direction: column; + gap: var(--gap-s); +} + +.stack-m { + display: flex; + flex-direction: column; + gap: var(--gap-m); +} + +.stack-l { + display: flex; + flex-direction: column; + gap: var(--gap-l); +} + +.stack-xl { + display: flex; + flex-direction: column; + gap: var(--gap-xl); +} + +.stack-xxl { + display: flex; + flex-direction: column; + gap: var(--gap-xxl); +} + +.stack-xxxl { + display: flex; + flex-direction: column; + gap: var(--gap-xxxl); +} + +.install-layout { + width: 100%; +} + +.install-card { + width: 100%; + padding: var(--space-2); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-l); + border: var(--border-width-s) solid var(--border-neutral); +} + +.install-panel { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--gap-xxxs); + transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-header { + padding: var(--space-4); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--gap-xxxs); +} + +.install-list { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; +} + +.install-row { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0; + background: var(--bgcolor-neutral-primary); + border: var(--border-width-s) solid var(--border-neutral); + border-radius: 0; + overflow: hidden; + opacity: 1; + transform: translateY(0); + transition: opacity 0.2s ease, + transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform, opacity; +} + +.install-row-main { + display: flex; + align-items: center; + gap: var(--gap-s); + height: 44px; + padding: 0 var(--space-6); + transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), + opacity 0.2s ease, + transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-row-label { + display: flex; + align-items: center; + gap: var(--gap-s); + min-width: 0; + min-height: 0; + padding-block-start: 0; +} + +.install-text { + min-width: 0; + display: inline-flex; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.install-text.is-enter { + opacity: 0; + transform: translateY(10px); +} + +.install-row-toggle { + margin-left: auto; + width: 32px; + height: 32px; + border: none; + border-radius: var(--border-radius-xs); + background: transparent; + color: var(--fgcolor-neutral-secondary); + display: none; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: background-color var(--duration-short) var(--ease-standard), + color var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard); +} + +.install-row-toggle svg { + width: 20px; + height: 20px; +} + + +.install-row-details { + max-height: none; + opacity: 0; + overflow: hidden; + display: grid; + grid-template-rows: 0fr; + padding: 0; + border: none; + transition: grid-template-rows var(--duration-medium) var(--ease-standard), + opacity var(--duration-medium) var(--ease-standard); +} + +.install-row:first-child { + border-top-left-radius: var(--border-radius-m); + border-top-right-radius: var(--border-radius-m); +} + +.install-row + .install-row { + margin-top: -1px; +} + +.install-row:last-child { + border-bottom-left-radius: var(--border-radius-m); + border-bottom-right-radius: var(--border-radius-m); +} + +.install-row.is-entering { + opacity: 0; + transform: translateY(-20px); +} + +.install-row.is-entering .install-row-main { + height: 0; + opacity: 0; + transform: translateY(-20px); +} + +.install-icon { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.install-icon-spinner, +.install-icon-check { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-icon-error { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1); + color: var(--fgcolor-error); +} + +.install-icon-check { + opacity: 0; +} + +.install-icon-spinner svg { + animation: none; + transform: rotate(var(--spinner-rotation)); +} + +.install-row[data-status='completed'] .install-icon-spinner { + opacity: 0; +} + +.install-row[data-status='completed'] .install-icon-spinner svg { + animation: none; +} + +.install-row[data-status='completed'] .install-icon-check { + opacity: 1; +} + +/* Keep the checkmark subtle: rely on container fade only (no stroke drawing). */ + +.install-row[data-status='error'] { + height: auto; + overflow: hidden; +} + +.install-row[data-status='error'] .install-icon-spinner, +.install-row[data-status='error'] .install-icon-check { + opacity: 0; +} + +.install-row[data-status='error'] .install-icon-error { + opacity: 1; +} + +.install-row[data-status='error'] .install-row-toggle { + opacity: 1; + pointer-events: auto; + display: inline-flex; +} + +.install-row[data-status='error'] { + cursor: pointer; +} + +.install-row[data-status='error'] .button { + cursor: pointer; +} + +.install-row.is-open .install-row-toggle { + transform: rotate(180deg); +} + +.install-row.is-open .install-row-details { + grid-template-rows: 1fr; + opacity: 1; +} + +.install-row-details-inner { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0; + border-top: var(--border-width-s) solid var(--border-neutral); + border-left: none; + border-right: none; + border-radius: 0; + background: var(--bgcolor-neutral-primary); +} + +.install-error-code { + margin: 0; + padding: var(--space-4) var(--space-6); + width: 100%; + background: var(--bgcolor-neutral-default); + border: none; + border-radius: 0; + font-family: var(--font-family-code), var(--mono-fallbacks), monospace; + font-size: var(--font-size-xs); + line-height: 140%; + letter-spacing: 0; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + max-height: 160px; + overflow-y: auto; + overflow-x: auto; + color: var(--fgcolor-neutral-primary); + scrollbar-width: none; + -ms-overflow-style: none; +} + +.install-error-code::-webkit-scrollbar { + width: 0; + height: 0; +} + + +.install-error-actions { + padding: var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-primary); + border-top: var(--border-width-s) solid var(--border-neutral); + border-radius: 0 0 var(--border-radius-m) var(--border-radius-m); + display: flex; + justify-content: flex-end; + flex-direction: row; + gap: var(--gap-m); +} + +.install-error-details .button { + align-self: center; + margin-top: 0; +} + +@keyframes install-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.typography-title-s { + font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-l); + font-weight: 400; + line-height: 130%; + letter-spacing: -0.144px; +} + +.typography-title-s, +.typography-text-m-400, +.typography-text-m-500 { + margin: 0; +} + +.typography-text-m-400 { + font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-s); + font-weight: 400; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-text-m-500 { + font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-s); + font-weight: 500; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-text-xs-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 400; + line-height: 130%; + letter-spacing: -0.12px; +} + +.typography-text-xs-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 130%; + letter-spacing: -0.12px; +} + +.typography-caption-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 400; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-caption-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 140%; + letter-spacing: -0.063px; +} + +.label-text { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.label-optional { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-1); +} + +.label-info-button { + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + color: var(--fgcolor-neutral-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; +} + +.label-info-button svg { + width: 16px; + height: 16px; +} + +.text-neutral-primary { + color: var(--fgcolor-neutral-primary); +} + +.text-neutral-secondary { + color: var(--fgcolor-neutral-secondary); +} + +.text-neutral-tertiary { + color: var(--fgcolor-neutral-tertiary); +} + +.text-warning { + color: var(--fgcolor-warning); +} + +.text-error { + color: var(--fgcolor-error); +} + +.text-on-success-weak { + color: var(--fgcolor-on-success-weak); +} + +.text-on-invert { + color: var(--fgcolor-on-invert); +} + +.input-group { + width: 100%; +} + +.field-error { + display: flex; + align-items: flex-start; + gap: var(--space-3); + color: var(--fgcolor-error); + max-height: 0; + opacity: 0; + overflow: hidden; + /*padding-inline-start: var(--space-2);*/ + transition: max-height var(--duration-extended) var(--ease-standard), + opacity var(--duration-extended) var(--ease-standard); +} + +.field-error-icon { + display: flex; + flex-shrink: 0; + align-items: center; +} + +.field-error-icon svg { + width: 1rem; + height: 1rem; +} + +.field-error.is-visible { + opacity: 1; + max-height: 64px; +} + +.field-helper { + display: flex; + align-items: flex-start; + gap: var(--space-3); + color: var(--fgcolor-neutral-secondary); +} + +.field-helper-icon { + display: flex; + flex-shrink: 0; + align-items: center; +} + +.field-helper-icon svg { + width: 1rem; + height: 1rem; +} + +.input-field { + width: 100%; + padding: var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-default); + border: var(--border-width-s) solid var(--border-neutral); + outline: none; + border-radius: var(--border-radius-s); + transition: all var(--duration-fast) var(--ease-in-out); +} + +.input-field:focus { + border-color: var(--border-focus); + box-shadow: inset 0 0 0 1px var(--border-focus); +} + +.input-field.is-error { + border-color: var(--border-error); + box-shadow: none; +} + +.input-field.is-error:focus { + border-color: var(--border-error); + box-shadow: inset 0 0 0 1px var(--border-error); +} + +.input-field::placeholder { + color: var(--fgcolor-neutral-tertiary); +} + +.input-field[type='number'] { + appearance: textfield; + -moz-appearance: textfield; +} + +.input-field[type='number']::-webkit-outer-spin-button, +.input-field[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.selector-group { + display: flex; + gap: var(--gap-m); + width: 100%; + position: relative; + overflow: visible; +} + +.selector-card { + position: relative; + flex: 1; + min-height: 52px; + display: flex; + align-items: center; + gap: var(--gap-s); + padding: var(--space-4) var(--space-6); + background: var(--bgcolor-neutral-default); + border: none; + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral); + border-radius: var(--border-radius-s); + cursor: pointer; + overflow: hidden; + transition: all var(--duration-fast) var(--ease-in-out); +} + +.selector-card.has-tooltip { + overflow: visible; +} + +.selector-card::before { + content: ''; + position: absolute; + inset: 0; + background: var(--overlay-neutral-hover); + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-in-out); +} + +.selector-card:hover::before, +.selector-card.selected::before { + opacity: 1; +} + +.selector-card.is-disabled::before, +.selector-card.is-disabled:hover::before { + opacity: 0; +} + +.selector-group.is-locked .selector-card { + cursor: default; +} + +.selector-group.is-locked .selector-card:hover::before { + opacity: 0; +} + +.selector-card.has-tooltip .tooltip { + top: calc(100% + 6px); + bottom: auto; + transform: translateX(-50%) translateY(-8px); +} + +.selector-card.has-tooltip { + z-index: 0; +} + +.selector-card.has-tooltip:hover { + z-index: 3; +} + +.selector-card.has-tooltip:hover .tooltip, +.selector-card.has-tooltip:focus-within .tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.tooltip-db-locked { + width: 193px; + max-width: none; + min-width: 193px; + text-align: center; +} +.selector-card.selected { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger); +} + +.selector-card:focus-within { + outline: none; + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral), + inset 0 0 0 var(--border-width-l) var(--border-focus); +} + +.selector-card.selected:focus-within { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger), + inset 0 0 0 var(--border-width-l) var(--border-focus); +} + +.selector-content { + position: relative; + display: flex; + flex-direction: column; + gap: 0; + flex: 1; +} + +.selector-icon { + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.accordion { + display: flex; + flex-direction: column; + gap: 0; +} + +.accordion-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-s); + width: 100%; + padding: 0; + border: none; + border-radius: var(--border-radius-s); + background: transparent; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-in-out); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; +} + +.accordion-toggle:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); +} + +.accordion-chevron { + width: 20px; + height: 20px; + transition: transform var(--duration-slow) var(--ease-in-out); + color: var(--fgcolor-neutral-tertiary); +} + +.accordion-chevron[data-open='true'] { + transform: rotate(180deg); +} + +.accordion-content { + display: flex; + flex-direction: column; + gap: var(--gap-l); + width: 100%; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height var(--duration-medium) var(--ease-out), + opacity var(--duration-medium) var(--ease-out); +} + +.accordion-content.open { + opacity: 1; + overflow: visible; + margin-top: var(--gap-m); +} + +.divider { + width: 100%; + height: 1px; + background: var(--border-neutral); + margin-top: var(--divider-gap-top, var(--gap-xl)); + margin-bottom: var(--gap-xl); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--gap-xs); + padding: var(--space-3) var(--space-5); + min-height: var(--space-10); + min-width: var(--button-min-width-s); + border-radius: var(--border-radius-s); + border: none; + box-shadow: inset 0 0 0 var(--border-width-s) transparent; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-in-out); + outline-offset: var(--border-width-s); + background: transparent; + color: var(--fgcolor-neutral-secondary); +} + +.button-text { + display: inline-flex; + align-items: center; +} + +.button:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); +} + +.button.primary { + background: var(--bgcolor-accent); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent); + color: var(--fgcolor-on-accent); +} + +.button.primary:hover { + background: var(--bgcolor-accent-secondary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-secondary); +} + +.button.primary:active { + background: var(--bgcolor-accent-tertiary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-tertiary); +} + +.button.primary:disabled { + background: var(--bgcolor-neutral-invert-weaker); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-neutral-invert-weaker); +} + +.button.secondary { + background: var(--bgcolor-neutral-primary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral); + color: var(--fgcolor-neutral-primary); +} + +.button.secondary:hover { + background: var(--bgcolor-neutral-secondary); +} + +.button.secondary:active { + background: var(--bgcolor-neutral-tertiary); +} + +.button.secondary:disabled { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-strong); +} + +.button:disabled { + opacity: 0.4; + pointer-events: none; +} + +.action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-xl); + width: 100%; + flex-wrap: nowrap; +} + +.step-indicators { + display: flex; + align-items: center; + line-height: 0; +} + +.step-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} + +.step-indicator svg { + display: block; +} + +.step-indicator .indicator-active { + display: none; +} + +.step-indicator.is-active .indicator-active { + display: inline-flex; +} + +.step-indicator.is-active .indicator-inactive { + display: none; +} + +.step-indicator.is-hidden { + display: none; +} + +.step-layout { + display: flex; + flex-direction: column; + gap: 0; +} + +.inline-alert { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--gap-m); + width: 100%; + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-neutral-strong); + outline-offset: -1px; + --alert-primary-color: var(--fgcolor-neutral-secondary); +} + +.inline-alert--warning { + background: var(--bgcolor-warning-weaker); + outline-color: var(--border-warning-weak); + --alert-primary-color: var(--fgcolor-warning); +} + +.inline-alert-content { + display: inline-flex; + align-items: flex-start; + gap: var(--gap-s); + align-self: stretch; +} + +.inline-alert-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--alert-primary-color); + flex-shrink: 0; +} + +.inline-alert-text { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.input-action { + display: flex; + align-items: center; + gap: var(--gap-l); + width: 100%; + padding: var(--space-3) var(--space-5) var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-s); + border: var(--border-width-s) solid var(--border-neutral); + outline: none; + transition: all var(--duration-fast) var(--ease-in-out); +} + +.input-action:focus-within { + border-color: var(--border-focus); + box-shadow: inset 0 0 0 1px var(--border-focus); +} + +.input-action.is-error { + border-color: var(--border-error); + box-shadow: none; +} + +.input-action.is-error:focus-within { + border-color: var(--border-error); + box-shadow: inset 0 0 0 1px var(--border-error); +} + +.input-action-input { + flex: 1; + border: none; + background: transparent; + padding: 0; + outline: none; +} + +.input-action-buttons { + display: flex; + align-items: center; + gap: var(--gap-m); +} + +.tooltip-wrapper { + position: relative; + display: inline-flex; +} + +.tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%) translateY(8px); + display: inline-flex; + align-items: center; + justify-content: center; + width: max-content; + max-width: 11.25rem; + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-s); + background: var(--bgcolor-neutral-invert-weak); + color: var(--fgcolor-on-invert); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s linear var(--duration-short); + z-index: 5; +} + +.tooltip.is-open { + opacity: 1; + visibility: visible; + transform: translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.tooltip-portal { + position: fixed; + left: 0; + top: 0; + bottom: auto; + transform: translateY(8px); +} + +.tooltip-assistant { + width: 246px; + max-width: 246px; + text-align: left; +} + +.tooltip-wrapper:hover .tooltip, +.tooltip-wrapper:focus-within .tooltip, +.tooltip-wrapper.is-open .tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.input-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--fgcolor-neutral-secondary); + cursor: pointer; + border-radius: var(--border-radius-xs); +} + +.input-icon-button svg { + width: 16px; + height: 16px; + display: block; +} + +.input-icon-button:hover { + background: var(--overlay-neutral-hover); +} + +.input-icon-button:active { + background: var(--overlay-neutral-pressed); +} + +.password-toggle-icon { + display: inline-flex; +} + +.password-toggle [data-password-icon="hide"] { + display: none; +} + +.password-toggle.is-visible [data-password-icon="show"] { + display: none; +} + +.password-toggle.is-visible [data-password-icon="hide"] { + display: inline-flex; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + background: var(--bgcolor-neutral-primary); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-neutral-strong); + outline-offset: -1px; + border: none; + color: var(--fgcolor-neutral-tertiary); + cursor: pointer; +} + +.icon-button.is-rotating svg { + animation: installer-rotate-once 0.35s linear; +} + +.input-row { + display: flex; + align-items: flex-start; + gap: var(--space-4); + width: 100%; +} + +.input-row .input-group { + flex: 1; +} + +.input-row .button { + margin-top: calc(var(--label-line-height) + var(--space-3)); +} + +.input-row .icon-button { + margin-top: calc(var(--label-line-height) + var(--space-3)); +} + +.review-card { + width: 100%; + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-m); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: -1px; + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.review-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-m); +} + +.review-label { + text-align: right; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-1) var(--space-2); + border-radius: 6px; +} + +.badge-success { + background: var(--bgcolor-success-weak); + color: var(--fgcolor-on-success-weak); +} + +.badge-warning { + background: var(--bgcolor-warning-weak); + color: var(--fgcolor-on-warning-weak); +} + +.badge-neutral { + background: var(--bgcolor-neutral-tertiary); + color: var(--fgcolor-neutral-secondary); +} + +.installer-footer { + width: 100%; + display: flex; + justify-content: center; + padding-bottom: var(--space-7); + position: relative; + z-index: 1; +} + +.appwrite-logo { + width: 131px; + height: 25px; + position: relative; +} + +.appwrite-logo svg { + width: 100%; + height: 100%; + display: block; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} + +@keyframes installer-rotate-once { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .installer-page { + padding: var(--space-6); + gap: var(--gap-m); + } + + .installer-card { + padding: var(--space-6); + } + + .installer-step { + min-height: 0; + } + + .selector-group { + flex-direction: column; + } + + .input-row { + flex-direction: column; + align-items: stretch; + } + + .input-row .button { + align-self: flex-start; + margin-top: 0; + } + + .input-row .icon-button { + margin-top: 0; + } + + .step-layout[data-step='2'] .input-row { + flex-direction: row; + align-items: flex-end; + } + + .action-bar { + gap: var(--gap-s); + } +} diff --git a/app/views/install/installer/icons/appwrite-logo.svg b/app/views/install/installer/icons/appwrite-logo.svg new file mode 100644 index 0000000000..f0f2fa5af0 --- /dev/null +++ b/app/views/install/installer/icons/appwrite-logo.svg @@ -0,0 +1,13 @@ + diff --git a/app/views/install/installer/icons/appwrite-mark.svg b/app/views/install/installer/icons/appwrite-mark.svg new file mode 100644 index 0000000000..7dff2b5fc4 --- /dev/null +++ b/app/views/install/installer/icons/appwrite-mark.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/chevron-down.svg b/app/views/install/installer/icons/chevron-down.svg new file mode 100644 index 0000000000..74d9c14cfd --- /dev/null +++ b/app/views/install/installer/icons/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/copy.svg b/app/views/install/installer/icons/copy.svg new file mode 100644 index 0000000000..008d89098a --- /dev/null +++ b/app/views/install/installer/icons/copy.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/exclamation-circle.svg b/app/views/install/installer/icons/exclamation-circle.svg new file mode 100644 index 0000000000..3416951e48 --- /dev/null +++ b/app/views/install/installer/icons/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/eye-off.svg b/app/views/install/installer/icons/eye-off.svg new file mode 100644 index 0000000000..9c0b0a063b --- /dev/null +++ b/app/views/install/installer/icons/eye-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/install/installer/icons/eye.svg b/app/views/install/installer/icons/eye.svg new file mode 100644 index 0000000000..897b425bf3 --- /dev/null +++ b/app/views/install/installer/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/install/installer/icons/indicator-active.svg b/app/views/install/installer/icons/indicator-active.svg new file mode 100644 index 0000000000..e620bdf6e9 --- /dev/null +++ b/app/views/install/installer/icons/indicator-active.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/indicator-inactive.svg b/app/views/install/installer/icons/indicator-inactive.svg new file mode 100644 index 0000000000..78b5ef3ac0 --- /dev/null +++ b/app/views/install/installer/icons/indicator-inactive.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/info.svg b/app/views/install/installer/icons/info.svg new file mode 100644 index 0000000000..6f0524fb49 --- /dev/null +++ b/app/views/install/installer/icons/info.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/install-bg-1.svg b/app/views/install/installer/icons/install-bg-1.svg new file mode 100644 index 0000000000..7291974bee --- /dev/null +++ b/app/views/install/installer/icons/install-bg-1.svg @@ -0,0 +1,16 @@ + diff --git a/app/views/install/installer/icons/install-bg-2.svg b/app/views/install/installer/icons/install-bg-2.svg new file mode 100644 index 0000000000..9e49ddb99f --- /dev/null +++ b/app/views/install/installer/icons/install-bg-2.svg @@ -0,0 +1,16 @@ + diff --git a/app/views/install/installer/icons/install-check.svg b/app/views/install/installer/icons/install-check.svg new file mode 100644 index 0000000000..7d43d007c6 --- /dev/null +++ b/app/views/install/installer/icons/install-check.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/install-spinner.svg b/app/views/install/installer/icons/install-spinner.svg new file mode 100644 index 0000000000..543d2c4b22 --- /dev/null +++ b/app/views/install/installer/icons/install-spinner.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/lock.svg b/app/views/install/installer/icons/lock.svg new file mode 100644 index 0000000000..6a87c21345 --- /dev/null +++ b/app/views/install/installer/icons/lock.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/mariadb.svg b/app/views/install/installer/icons/mariadb.svg new file mode 100644 index 0000000000..921fbbb0dc --- /dev/null +++ b/app/views/install/installer/icons/mariadb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/views/install/installer/icons/mongodb.svg b/app/views/install/installer/icons/mongodb.svg new file mode 100644 index 0000000000..5470eccb38 --- /dev/null +++ b/app/views/install/installer/icons/mongodb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/install/installer/icons/refresh.svg b/app/views/install/installer/icons/refresh.svg new file mode 100644 index 0000000000..56f70b425a --- /dev/null +++ b/app/views/install/installer/icons/refresh.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/step-dot.svg b/app/views/install/installer/icons/step-dot.svg new file mode 100644 index 0000000000..490a039d20 --- /dev/null +++ b/app/views/install/installer/icons/step-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/views/install/installer/icons/warning.svg b/app/views/install/installer/icons/warning.svg new file mode 100644 index 0000000000..131b29171c --- /dev/null +++ b/app/views/install/installer/icons/warning.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js new file mode 100644 index 0000000000..940c0bc780 --- /dev/null +++ b/app/views/install/installer/js/constants.js @@ -0,0 +1,11 @@ +(() => { + window.InstallerConstants = Object.freeze({ + stepTransitionMs: 260, + errorClearMs: 180, + installPollIntervalMs: 4000, + installFallbackDelayMs: 12000, + redirectDelayMs: 2500, + progressTransitionDelayMs: 320, + progressCompleteDelayMs: 140, + }); +})(); diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js new file mode 100644 index 0000000000..463b7f6221 --- /dev/null +++ b/app/views/install/installer/js/installer.js @@ -0,0 +1,450 @@ +(() => { + const stepContainer = document.querySelector('.installer-step'); + const installerCard = document.querySelector('.installer-card'); + const backButton = document.querySelector('[data-action="back"]'); + const nextButton = document.querySelector('[data-action="next"]'); + const installScreen = document.querySelector('.install-screen-content'); + const indicatorNodes = Array.from(document.querySelectorAll('.step-indicator')); + const STEP_TRANSITION_TIMEOUT = window.InstallerConstants?.stepTransitionMs ?? 260; + + if (!stepContainer || !installerCard) return; + + const { validateInstallRequest } = window.InstallerStepsProgress || {}; + + const isUpgrade = document.body?.dataset.upgrade === 'true'; + const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5]; + const cardSteps = stepFlow.filter((step) => step !== 5); + + const normalizeStep = (step) => { + const numeric = clampStep(step); + if (stepFlow.includes(numeric)) return numeric; + if (numeric <= stepFlow[0]) return stepFlow[0]; + for (let i = 0; i < stepFlow.length; i += 1) { + if (numeric < stepFlow[i]) { + return stepFlow[i]; + } + } + return stepFlow[stepFlow.length - 1]; + }; + + const buildStepConfig = () => { + const config = {}; + stepFlow.forEach((step, index) => { + if (step === 5) { + config[step] = { back: { target: null }, next: { target: null } }; + return; + } + const prev = stepFlow[index - 1] ?? null; + const next = stepFlow[index + 1] ?? null; + const label = next === 5 ? (isUpgrade ? 'Update' : 'Install') : 'Next'; + config[step] = { + back: { target: prev }, + next: { label, target: next } + }; + }); + return config; + }; + + const STEP_CONFIG = buildStepConfig(); + + const stepCache = new Map(); + let maxStepHeight = 0; + let isTransitioning = false; + let pendingStep = null; + let pendingPushState = false; + + const clampStep = (step) => Math.max(1, Math.min(5, step)); + const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.()); + + const scrollToFirstError = (panel) => { + if (!panel) return; + const getErrorNode = () => panel.querySelector('.field-error.is-visible') + || panel.querySelector('.field-error') + || panel.querySelector('.input-field.is-error, .input-action.is-error'); + const container = panel.closest('.step-panel') || panel; + const attemptScroll = () => { + const target = getErrorNode(); + if (!target || typeof target.getBoundingClientRect !== 'function') return false; + const targetRect = target.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const targetTop = targetRect.top - containerRect.top + container.scrollTop; + const targetBottom = targetTop + targetRect.height; + const viewTop = container.scrollTop; + const viewBottom = viewTop + containerRect.height; + const padding = 12; + + let nextScrollTop = viewTop; + if (targetTop < viewTop + padding) { + nextScrollTop = Math.max(0, targetTop - padding); + } else if (targetBottom > viewBottom - padding) { + nextScrollTop = Math.max(0, targetBottom - containerRect.height + padding); + } + + if (Math.abs(nextScrollTop - viewTop) < 1) { + return false; + } + + container.scrollTo({ top: nextScrollTop, behavior: 'smooth' }); + return true; + }; + + let remaining = 20; + let lastScrollTop = -1; + const settle = () => { + if (remaining <= 0) return; + const moved = attemptScroll(); + remaining -= 1; + const currentTop = container.scrollTop; + const delta = Math.abs(currentTop - lastScrollTop); + lastScrollTop = currentTop; + if (!moved && delta < 0.5) { + return; + } + requestAnimationFrame(settle); + }; + requestAnimationFrame(settle); + }; + + const getStepFromUrl = () => { + const url = new URL(window.location.href); + const step = Number(url.searchParams.get('step') || 1); + return normalizeStep(Number.isNaN(step) ? 1 : step); + }; + + const buildStepUrl = (step) => { + const url = new URL(window.location.href); + url.searchParams.set('step', step); + return url; + }; + + const setStepInUrl = (step, pushState) => { + const url = new URL(window.location.href); + url.searchParams.set('step', step); + + if (pushState) { + window.history.pushState({ step }, '', url.toString()); + } + + return url; + }; + + const updateActionBar = (step) => { + const config = STEP_CONFIG[step] || STEP_CONFIG[1]; + if (!backButton || !nextButton) return; + const locked = isInstallLocked(); + + const setButtonLabel = (button, label) => { + if (!button) return; + let text = button.querySelector('.button-text'); + if (!text) { + text = document.createElement('span'); + text.className = 'button-text typography-text-m-500'; + button.textContent = ''; + button.appendChild(text); + } + text.textContent = label; + }; + + if (!locked && config.back?.target) { + backButton.disabled = false; + backButton.setAttribute('data-step-target', String(config.back.target)); + } else { + backButton.disabled = true; + backButton.removeAttribute('data-step-target'); + } + setButtonLabel(backButton, 'Back'); + + if (!locked && config.next?.target) { + setButtonLabel(nextButton, config.next?.label || 'Next'); + nextButton.setAttribute('data-step-target', String(config.next?.target || 1)); + nextButton.disabled = false; + } else { + setButtonLabel(nextButton, config.next?.label || 'Next'); + nextButton.removeAttribute('data-step-target'); + nextButton.disabled = true; + } + + indicatorNodes.forEach((node, index) => { + const isVisible = index < cardSteps.length; + node.classList.toggle('is-hidden', !isVisible); + if (!isVisible) { + node.classList.remove('is-active'); + return; + } + node.classList.toggle('is-active', cardSteps[index] === step); + }); + + installerCard.setAttribute('data-step', String(step)); + document.body.dataset.step = String(step); + if (locked) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const measureStepHeight = (panel) => { + if (!panel) return; + const height = panel.getBoundingClientRect().height; + if (!height) return; + maxStepHeight = Math.max(maxStepHeight, height); + stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`); + }; + + const runStepInit = (step, rootElement) => { + if (!window.InstallerSteps || typeof window.InstallerSteps.initStep !== 'function') return; + const root = rootElement || stepContainer; + window.InstallerSteps.initStep(step, root); + updateActionBar(step); + }; + + const fetchStepHtml = (step, url) => { + if (stepCache.has(step)) { + return Promise.resolve(stepCache.get(step)); + } + + const fetchUrl = new URL(url); + fetchUrl.searchParams.set('partial', '1'); + + return fetch(fetchUrl.toString(), { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to load step'); + } + return response.text(); + }) + .then((html) => { + stepCache.set(step, html); + return html; + }); + }; + + const preloadSteps = (steps) => { + const current = getStepFromUrl(); + const targets = steps.filter((step) => step !== current); + + return Promise.all( + targets.map((step) => { + const url = buildStepUrl(step); + return fetchStepHtml(step, url) + .then((html) => { + const panel = document.createElement('div'); + panel.className = 'step-panel is-measure'; + panel.innerHTML = html; + stepContainer.appendChild(panel); + panel.getBoundingClientRect(); + measureStepHeight(panel); + panel.remove(); + }) + .catch(() => null); + }) + ); + }; + + const swapPanels = (step, html, onDone) => { + const activePanel = stepContainer.querySelector('.step-panel'); + + const measurePanel = document.createElement('div'); + measurePanel.className = 'step-panel is-measure'; + measurePanel.innerHTML = html; + stepContainer.appendChild(measurePanel); + measurePanel.getBoundingClientRect(); + measureStepHeight(measurePanel); + measurePanel.remove(); + + const newPanel = document.createElement('div'); + newPanel.className = 'step-panel is-entering'; + newPanel.innerHTML = html; + stepContainer.appendChild(newPanel); + runStepInit(step, newPanel); + + newPanel.getBoundingClientRect(); + + requestAnimationFrame(() => { + newPanel.classList.remove('is-entering'); + newPanel.classList.add('is-active'); + if (activePanel) { + activePanel.classList.add('is-exiting'); + } + }); + + const finalize = () => { + if (activePanel && activePanel.parentNode) { + activePanel.parentNode.removeChild(activePanel); + } + newPanel.classList.remove('is-entering'); + if (typeof onDone === 'function') { + onDone(); + } + }; + + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReduced) { + finalize(); + return; + } + + let finished = false; + const finishOnce = () => { + if (finished) return; + finished = true; + finalize(); + }; + + newPanel.addEventListener( + 'transitionend', + (event) => { + if (event.propertyName === 'opacity') { + finishOnce(); + } + }, + { once: true } + ); + + setTimeout(finishOnce, STEP_TRANSITION_TIMEOUT); + }; + + const showInstallScreen = (step, html) => { + if (!installScreen) return; + installScreen.innerHTML = html; + runStepInit(step, installScreen); + }; + + const hideInstallScreen = () => { + if (!installScreen) return; + installScreen.innerHTML = ''; + }; + + const loadStep = (step, pushState) => { + const targetStep = normalizeStep(Number(step)); + const currentStep = getStepFromUrl(); + if (targetStep === currentStep && pushState) return; + + isTransitioning = true; + const url = setStepInUrl(targetStep, pushState); + + fetchStepHtml(targetStep, url) + .then((html) => { + if (targetStep === 5) { + showInstallScreen(targetStep, html); + isTransitioning = false; + if (pendingStep !== null && pendingStep !== targetStep) { + const nextStep = pendingStep; + const nextPushState = pendingPushState; + pendingStep = null; + pendingPushState = false; + loadStep(nextStep, nextPushState); + return; + } + pendingStep = null; + pendingPushState = false; + return; + } + + hideInstallScreen(); + swapPanels(targetStep, html, () => { + isTransitioning = false; + if (pendingStep !== null && pendingStep !== targetStep) { + const nextStep = pendingStep; + const nextPushState = pendingPushState; + pendingStep = null; + pendingPushState = false; + loadStep(nextStep, nextPushState); + return; + } + pendingStep = null; + pendingPushState = false; + }); + }) + .catch(() => { + isTransitioning = false; + window.location.href = url.toString(); + }); + }; + + const requestStep = (step, pushState) => { + const targetStep = normalizeStep(Number(step)); + if (isInstallLocked() && targetStep !== 5) { + loadStep(5, true); + return; + } + if (isTransitioning) { + pendingStep = targetStep; + pendingPushState = pendingPushState || pushState; + return; + } + loadStep(targetStep, pushState); + }; + + document.addEventListener('click', async (event) => { + const button = event.target.closest('[data-step-target]'); + if (!button || button.disabled) return; + event.preventDefault(); + const target = button.getAttribute('data-step-target'); + if (!target) return; + const action = button.getAttribute('data-action'); + if (action === 'next') { + const currentStep = getStepFromUrl(); + const panel = stepContainer.querySelector('.step-panel') || stepContainer; + const validator = window.InstallerSteps?.validateStep; + if (typeof validator === 'function') { + const valid = validator(currentStep, panel); + if (!valid) { + scrollToFirstError(panel); + return; + } + } + } + if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + } + if (isInstallLocked() && Number(target) !== 5) { + requestStep(5, true); + return; + } + requestStep(target, true); + }); + + window.addEventListener('popstate', (event) => { + const step = event.state?.step || getStepFromUrl(); + if (isInstallLocked() && Number(step) !== 5) { + requestStep(5, false); + return; + } + requestStep(step, false); + }); + + document.addEventListener('DOMContentLoaded', () => { + let step = getStepFromUrl(); + if (isInstallLocked() && step !== 5) { + const url = buildStepUrl(5); + window.history.replaceState({ step: 5 }, '', url.toString()); + step = 5; + } else { + const url = buildStepUrl(step); + window.history.replaceState({ step }, '', url.toString()); + } + const activePanel = stepContainer.querySelector('.step-panel') || stepContainer; + runStepInit(step, activePanel); + measureStepHeight(activePanel); + if (step === 5 && installScreen) { + runStepInit(step, installScreen); + } + const preload = () => { + measureStepHeight(activePanel); + preloadSteps(cardSteps); + }; + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(preload).catch(preload); + } else { + preload(); + } + }); +})(); diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js new file mode 100644 index 0000000000..bc08a8c8df --- /dev/null +++ b/app/views/install/installer/js/modules/context.js @@ -0,0 +1,105 @@ +(() => { + const getBodyDataset = () => document.body?.dataset ?? {}; + const isUpgradeMode = () => getBodyDataset().upgrade === 'true'; + const getLockedDatabase = () => getBodyDataset().lockedDatabase || ''; + + const STEP_IDS = Object.freeze({ + CONFIG_FILES: 'config-files', + DOCKER_COMPOSE: 'docker-compose', + ENV_VARS: 'env-vars', + DOCKER_CONTAINERS: 'docker-containers', + ACCOUNT_SETUP: 'account-setup' + }); + + const STATUS = Object.freeze({ + IN_PROGRESS: 'in-progress', + COMPLETED: 'completed', + ERROR: 'error' + }); + + const SSE_EVENTS = Object.freeze({ + PING: 'ping', + INSTALL_ID: 'install-id', + PROGRESS: 'progress', + DONE: 'done', + ERROR: 'error' + }); + + const buildInstallationSteps = (upgrade) => (upgrade ? [ + { + id: STEP_IDS.CONFIG_FILES, + inProgress: 'Updating configuration files...', + done: 'Configuration files updated' + }, + { + id: STEP_IDS.DOCKER_COMPOSE, + inProgress: 'Updating Docker Compose file...', + done: 'Docker Compose file updated' + }, + { + id: STEP_IDS.ENV_VARS, + inProgress: 'Updating environment variables...', + done: 'Environment variables updated' + }, + { + id: STEP_IDS.DOCKER_CONTAINERS, + inProgress: 'Restarting Docker containers...', + done: 'Docker containers restarted' + } + ] : [ + { + id: STEP_IDS.CONFIG_FILES, + inProgress: 'Creating configuration files...', + done: 'Configuration files created' + }, + { + id: STEP_IDS.DOCKER_COMPOSE, + inProgress: 'Generating Docker Compose file...', + done: 'Docker Compose file generated' + }, + { + id: STEP_IDS.ENV_VARS, + inProgress: 'Configuring environment variables...', + done: 'Environment variables configured' + }, + { + id: STEP_IDS.DOCKER_CONTAINERS, + inProgress: 'Starting Docker containers...', + done: 'Docker containers started' + }, + { + id: STEP_IDS.ACCOUNT_SETUP, + inProgress: 'Creating Appwrite account...', + done: 'Appwrite account created (redirecting...)' + } + ]); + + const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode()); + const CONSTANTS = window.InstallerConstants || {}; + const TIMINGS = { + errorClear: CONSTANTS.errorClearMs ?? 180, + installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000, + installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, + redirectDelay: CONSTANTS.redirectDelayMs ?? 500, + progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140, + progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120 + }; + + const clampStep = (step) => { + const numeric = Number(step); + if (Number.isNaN(numeric)) return 1; + return Math.max(1, Math.min(5, numeric)); + }; + + window.InstallerStepsContext = Object.freeze({ + getBodyDataset, + isUpgradeMode, + getLockedDatabase, + STEP_IDS, + STATUS, + SSE_EVENTS, + INSTALLATION_STEPS, + TIMINGS, + clampStep + }); +})(); diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js new file mode 100644 index 0000000000..02c1e0ef2b --- /dev/null +++ b/app/views/install/installer/js/modules/progress.js @@ -0,0 +1,899 @@ +(() => { + const { + INSTALLATION_STEPS, + TIMINGS, + getBodyDataset, + STEP_IDS, + STATUS, + SSE_EVENTS + } = window.InstallerStepsContext; + const { + formState, + applyLockPayload, + applyBodyDefaults, + setInstallLock, + getInstallLock, + clearInstallLock, + isInstallLocked, + syncInstallLockFlag, + getStoredInstallId, + storeInstallId, + clearInstallId + } = window.InstallerStepsState || {}; + const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {}; + const { generateSecretKey } = window.InstallerStepsUI || {}; + const { showToast } = window.InstallerToast || {}; + + let activeInstall = null; + let unloadGuard = null; + const csrfToken = document.querySelector('meta[name="appwrite-installer-csrf"]')?.getAttribute('content') || ''; + + const withCsrfHeader = (headers = {}) => { + if (!csrfToken) { + return headers; + } + return { ...headers, 'X-Appwrite-Installer-CSRF': csrfToken }; + }; + + const showCsrfToast = () => { + showToast?.({ + status: 'error', + title: 'Session expired', + description: 'Refresh the page and try again.', + dismissible: true + }); + }; + + const validateInstallRequest = async () => { + try { + const response = await fetch('/install/validate', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }) + }); + if (!response.ok) { + showCsrfToast(); + return false; + } + const data = await response.json().catch(() => ({})); + if (!data?.success) { + showCsrfToast(); + return false; + } + return true; + } catch (error) { + showCsrfToast(); + return false; + } + }; + + const setUnloadGuard = (enabled) => { + if (!enabled && unloadGuard) { + window.removeEventListener('beforeunload', unloadGuard); + unloadGuard = null; + return; + } + + if (enabled && !unloadGuard) { + unloadGuard = (event) => { + event.preventDefault(); + event.returnValue = ''; + return ''; + }; + window.addEventListener('beforeunload', unloadGuard); + } + }; + + const cleanupInstallFlow = () => { + if (activeInstall?.controller) { + activeInstall.controller.abort(); + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + } + stopSyncedSpinnerRotation(); + setUnloadGuard(false); + }; + + const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id); + + const getProgressLabel = (step, status, message) => { + if (!step) return message || ''; + if (status === STATUS.ERROR) { + const normalized = normalizeInstallError(message || ''); + return normalized.summary || 'Installation failed.'; + } + if (status === STATUS.COMPLETED) return step.done; + return step.inProgress; + }; + + const updateInstallRow = (row, step, status, message) => { + if (!row || !step) return; + row.dataset.status = status; + row.dataset.step = step.id; + if (status !== STATUS.ERROR) { + row.classList.remove('is-open'); + const toggle = row.querySelector('[data-install-toggle]'); + if (toggle) { + toggle.setAttribute('aria-expanded', 'false'); + } + } + const label = getProgressLabel(step, status, message); + const text = row.querySelector('[data-install-text]'); + if (text) { + if (text.textContent !== label) { + text.classList.remove('is-enter'); + text.textContent = label; + text.classList.add('is-enter'); + requestAnimationFrame(() => { + text.classList.remove('is-enter'); + }); + } + } + + // Show/hide "Navigate to Console" button for account setup errors + const consoleBtn = row.querySelector('[data-install-console]'); + if (consoleBtn) { + const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR; + consoleBtn.classList.toggle('is-hidden', !shouldShow); + } + }; + + const normalizeInstallError = (message) => { + const text = String(message || '').trim(); + if (!text) { + return { summary: '', details: '' }; + } + const colonIndex = text.indexOf(':'); + if (colonIndex > 0 && colonIndex < 80) { + const summary = text.slice(0, colonIndex).trim(); + const details = text.slice(colonIndex + 1).trim(); + return { summary, details }; + } + if (text.length > 180) { + return { summary: text.slice(0, 180).trim() + '…', details: text }; + } + return { summary: text, details: '' }; + }; + + let spinnerAnimationFrame = null; + const stopSyncedSpinnerRotation = () => { + if (spinnerAnimationFrame) { + cancelAnimationFrame(spinnerAnimationFrame); + spinnerAnimationFrame = null; + } + }; + + const startSyncedSpinnerRotation = (container) => { + stopSyncedSpinnerRotation(); + if (!container) return; + let startTime = null; + const animate = (timestamp) => { + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + const rotation = ((elapsed / 1000) * 360 * 1.5) % 360; + container.style.setProperty('--spinner-rotation', `${rotation}deg`); + spinnerAnimationFrame = requestAnimationFrame(animate); + }; + spinnerAnimationFrame = requestAnimationFrame(animate); + }; + + const updateInstallErrorDetails = (row, error) => { + if (!row) return; + const traceNode = row.querySelector('[data-install-trace]'); + const normalized = normalizeInstallError(error?.message || ''); + const output = error?.output || ''; + const trace = error?.trace || ''; + const detailChunks = []; + if (normalized.details) detailChunks.push(normalized.details); + if (output) detailChunks.push(output); + if (trace) detailChunks.push(trace); + const detailText = detailChunks.join('\n\n'); + + if (traceNode) { + traceNode.textContent = detailText; + traceNode.style.display = detailText ? 'block' : 'none'; + } + }; + + const createInstallRow = (template, step) => { + const fragment = template.content.cloneNode(true); + const row = fragment.querySelector('.install-row'); + if (!row) return null; + const toggle = row.querySelector('[data-install-toggle]'); + const setOpenState = (isOpen) => { + row.classList.toggle('is-open', isOpen); + if (toggle) { + toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + } + }; + const toggleRow = () => { + if (!row.dataset.status || row.dataset.status !== STATUS?.ERROR) { + return; + } + setOpenState(!row.classList.contains('is-open')); + }; + row.addEventListener('click', (event) => { + if (event.target.closest('[data-install-retry]')) { + return; + } + if (event.target.closest('[data-install-toggle]')) { + return; + } + if (event.target.closest('.install-row-details')) { + return; + } + toggleRow(); + }); + if (toggle) { + toggle.addEventListener('click', (event) => { + event.stopPropagation(); + toggleRow(); + }); + } + updateInstallRow(row, step, STATUS.IN_PROGRESS); + return row; + }; + + const generateInstallId = () => { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const buildRedirectUrl = () => { + const dataset = getBodyDataset?.() ?? {}; + const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim(); + if (!rawDomain) return ''; + const httpPort = (formState?.httpPort || dataset.defaultHttpPort || '').trim(); + const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim(); + const hasPort = rawDomain.includes(':') || rawDomain.startsWith('['); + let host = rawDomain; + const hostForProtocol = extractHostname?.(rawDomain); + const normalizedHost = hostForProtocol?.toLowerCase?.() ?? ''; + if (hostForProtocol === '0.0.0.0') { + host = rawDomain.replace('0.0.0.0', 'localhost'); + } else if (normalizedHost === 'traefik') { + host = rawDomain.replace(hostForProtocol, 'localhost'); + } + let protocol = 'http'; + let port = httpPort; + if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) { + protocol = 'https'; + port = httpsPort; + } + if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) { + host = `${rawDomain}:${port}`; + } + return `${protocol}://${host}`; + }; + + const redirectToApp = () => { + const url = buildRedirectUrl(); + if (!url) return; + window.location.href = url; + }; + + const notifyInstallComplete = (installId, session) => { + if (!installId) return Promise.resolve(); + const payload = { installId }; + const sessionSecret = session?.sessionSecret || session?.secret; + const sessionId = session?.sessionId || session?.id; + const sessionExpire = session?.sessionExpire || session?.expire; + if (sessionSecret) { + payload.sessionSecret = sessionSecret; + } + if (sessionId) { + payload.sessionId = sessionId; + } + if (sessionExpire) { + payload.sessionExpire = sessionExpire; + } + return fetch('/install/complete', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify(payload) + }).catch(() => {}); + }; + + const buildInstallPayload = (installId) => { + const normalizedSecret = (formState?.opensslKey || '').trim(); + if (!normalizedSecret && generateSecretKey) { + formState.opensslKey = generateSecretKey(); + } + const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost'; + const normalizedHttpPort = (formState?.httpPort || '').trim() || '80'; + const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443'; + const normalizedEmail = (formState?.emailCertificates || '').trim(); + const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim(); + const normalizedAccountEmail = (formState?.accountEmail || '').trim(); + const normalizedAccountPassword = (formState?.accountPassword || '').trim(); + + return { + installId, + httpPort: normalizedHttpPort, + httpsPort: normalizedHttpsPort, + database: formState?.database || 'mongodb', + appDomain: normalizedDomain, + emailCertificates: normalizedEmail, + opensslKey: (formState?.opensslKey || '').trim(), + assistantOpenAIKey: normalizedAssistantKey, + accountEmail: normalizedAccountEmail, + accountPassword: normalizedAccountPassword + }; + }; + + const fetchInstallStatus = async (installId) => { + if (!installId) return null; + const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, { + cache: 'no-store' + }); + if (!response.ok) return null; + const json = await response.json(); + return json.progress || null; + }; + + const readEventStream = async (stream, onEvent) => { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + const processEvent = (rawEvent) => { + if (!rawEvent) return; + const lines = rawEvent.split('\n'); + let eventName = 'message'; + let data = ''; + + lines.forEach((line) => { + if (line.startsWith('event:')) { + eventName = line.replace('event:', '').trim(); + } else if (line.startsWith('data:')) { + data += line.replace('data:', '').trim(); + } + }); + + if (data) { + try { + const parsed = JSON.parse(data); + onEvent(eventName, parsed); + } catch (error) { + onEvent(eventName, { message: data }); + } + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + buffer = buffer.replace(/\r\n/g, '\n'); + if (buffer.trim()) { + processEvent(buffer); + } + break; + } + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, '\n'); + let separatorIndex = buffer.indexOf('\n\n'); + + while (separatorIndex !== -1) { + const rawEvent = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + processEvent(rawEvent); + separatorIndex = buffer.indexOf('\n\n'); + } + } + } finally { + try { + reader.releaseLock(); + } catch (error) {} + } + }; + + const initStep5 = (root) => { + if (!root) return; + + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + + const list = root.querySelector('[data-install-list]'); + const template = root.querySelector('#install-row-template'); + if (!list || !template) return; + startSyncedSpinnerRotation(list); + + list.innerHTML = ''; + const rowsById = new Map(); + const progressState = new Map(); + let pendingProgressTimer = null; + let pendingProgressStep = null; + let pendingCompletionTimer = null; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + + const ensureRow = (step) => { + if (!step) return null; + if (rowsById.has(step.id)) { + return rowsById.get(step.id); + } + const row = createInstallRow(template, step); + if (!row) return null; + row.classList.add('is-entering'); + list.appendChild(row); + row.getBoundingClientRect(); + requestAnimationFrame(() => { + row.classList.remove('is-entering'); + }); + rowsById.set(step.id, row); + return row; + }; + + const installPanel = root.querySelector('.install-panel'); + let panelHeightCleanup = null; + const animatePanelHeight = (mutate) => { + if (!installPanel) { + mutate(); + return; + } + if (panelHeightCleanup) { + panelHeightCleanup(); + panelHeightCleanup = null; + } + const currentHeight = installPanel.getBoundingClientRect().height; + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + mutate(); + const nextHeight = installPanel.getBoundingClientRect().height; + if (currentHeight === nextHeight) { + installPanel.style.height = ''; + return; + } + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + installPanel.style.height = `${nextHeight}px`; + const cleanup = () => { + installPanel.style.height = ''; + installPanel.removeEventListener('transitionend', onEnd); + }; + const onEnd = (event) => { + if (event.propertyName === 'height') { + cleanup(); + } + }; + panelHeightCleanup = cleanup; + installPanel.addEventListener('transitionend', onEnd); + }; + + const renderProgress = () => { + animatePanelHeight(() => { + const visibleSteps = []; + for (const step of INSTALLATION_STEPS) { + const state = progressState.get(step.id); + if (!state) break; + visibleSteps.push(step); + } + + visibleSteps.forEach((step) => { + const state = progressState.get(step.id); + if (!state) return; + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message); + if (state.status === STATUS?.ERROR) { + updateInstallErrorDetails(row, { + message: state.message, + trace: state.details?.trace, + output: state.details?.output + }); + } + } + }); + }); + }; + + const firstStep = INSTALLATION_STEPS[0]; + if (firstStep) { + progressState.set(firstStep.id, { + status: STATUS.IN_PROGRESS, + message: firstStep.inProgress + }); + } + renderProgress(); + + const applyProgress = (payload) => { + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + progressState.set(step.id, { + status: payload.status || STATUS.IN_PROGRESS, + message: payload.message, + details: payload.details + }); + renderProgress(); + if (activeInstall) { + activeInstall.lastEventAt = Date.now(); + if (payload.status === STATUS.ERROR) { + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + activeInstall.pollTimer = null; + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + activeInstall.fallbackTimer = null; + } + } + } + scheduleFallback(); + }; + + const handleProgress = (payload) => { + if (!payload || !payload.step) return; + + const existingState = progressState.get(payload.step); + if (existingState && existingState.status === STATUS.COMPLETED && payload.status === STATUS.IN_PROGRESS) { + return; + } + + if (pendingProgressTimer && pendingProgressStep === payload.step && payload.status !== STATUS.IN_PROGRESS) { + clearTimeout(pendingProgressTimer); + pendingProgressTimer = null; + pendingProgressStep = null; + } + if (pendingCompletionTimer) { + clearTimeout(pendingCompletionTimer); + pendingCompletionTimer = null; + } + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + if (payload.status === STATUS.IN_PROGRESS) { + const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id); + const completionTargets = []; + if (currentIndex > 0) { + for (let i = 0; i < currentIndex; i += 1) { + const previousStep = INSTALLATION_STEPS[i]; + const previousState = progressState.get(previousStep.id); + if (previousState && previousState.status !== STATUS.COMPLETED) { + completionTargets.push({ + step: previousStep, + details: previousState.details + }); + } + } + } + if (completionTargets.length && currentIndex > 0) { + pendingProgressStep = payload.step; + pendingCompletionTimer = setTimeout(() => { + pendingCompletionTimer = null; + completionTargets.forEach(({ step: previousStep, details }) => { + progressState.set(previousStep.id, { + status: STATUS.COMPLETED, + message: previousStep.done, + details + }); + }); + renderProgress(); + }, TIMINGS?.progressCompleteDelay ?? 0); + pendingProgressTimer = setTimeout(() => { + pendingProgressTimer = null; + pendingProgressStep = null; + applyProgress(payload); + }, TIMINGS?.progressTransitionDelay ?? 0); + scheduleFallback(); + return; + } + } + applyProgress(payload); + }; + + const applySnapshot = (snapshot) => { + if (!snapshot || !snapshot.steps) return; + INSTALLATION_STEPS.forEach((step) => { + const detail = snapshot.steps[step.id]; + if (!detail) return; + progressState.set(step.id, { + status: detail.status, + message: detail.message, + details: snapshot.details?.[step.id] + }); + }); + renderProgress(); + }; + + const startPolling = () => { + if (!activeInstall || activeInstall.pollTimer) return; + activeInstall.pollTimer = setInterval(async () => { + if (!activeInstall || activeInstall.completed) return; + const snapshot = await fetchInstallStatus(activeInstall.installId); + if (snapshot) { + applySnapshot(snapshot); + } + }, TIMINGS?.installPollInterval ?? 0); + }; + + const scheduleFallback = () => { + if (!activeInstall) return; + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall.fallbackTimer = setTimeout(() => { + if (!activeInstall) return; + startPolling(); + }, TIMINGS?.installFallbackDelay ?? 0); + }; + + const finalizeInstall = () => { + if (!activeInstall) return; + activeInstall.completed = true; + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + stopSyncedSpinnerRotation(); + setUnloadGuard(false); + }; + + const startInstallStream = async (installId, options = {}) => { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + + const payload = buildInstallPayload(installId); + if (options.retryStep) { + payload.retryStep = options.retryStep; + } + setInstallLock?.(installId, payload); + setUnloadGuard(true); + + try { + scheduleFallback(); + const response = await fetch('/install', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }), + body: JSON.stringify(payload), + signal: activeInstall.controller.signal + }); + + if (!response.ok || !response.body) { + let errorMessage = null; + try { + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + const data = await response.json(); + errorMessage = data?.message || null; + } + } catch (error) { + errorMessage = null; + } + if (errorMessage) { + handleProgress({ + step: STEP_IDS.CONFIG_FILES, + status: STATUS.ERROR, + message: errorMessage + }); + finalizeInstall(); + return; + } + startPolling(); + return; + } + + await readEventStream(response.body, (event, data) => { + if (!activeInstall) return; + if (event === SSE_EVENTS.INSTALL_ID && data?.installId) { + activeInstall.installId = data.installId; + storeInstallId?.(data.installId); + return; + } + if (event === SSE_EVENTS.PROGRESS) { + handleProgress(data); + return; + } + if (event === SSE_EVENTS.DONE) { + const lastStep = INSTALLATION_STEPS[INSTALLATION_STEPS.length - 1]; + if (lastStep) { + const lastState = progressState.get(lastStep.id); + if (!lastState || (lastState.status !== STATUS.COMPLETED && lastState.status !== STATUS.ERROR)) { + progressState.set(lastStep.id, { + status: STATUS.COMPLETED, + message: lastStep.done, + details: lastState?.details + }); + renderProgress(); + } + } + const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); + const accountRequired = INSTALLATION_STEPS.some((step) => step.id === STEP_IDS.ACCOUNT_SETUP); + if (accountRequired && accountState?.status === STATUS.ERROR) { + finalizeInstall(); + return; + } + const sessionDetails = accountState?.details; + if (accountRequired && !(sessionDetails?.sessionSecret || sessionDetails?.secret)) { + handleProgress({ + step: STEP_IDS.ACCOUNT_SETUP, + status: STATUS.ERROR, + message: 'Account creation did not complete. Please retry.' + }); + finalizeInstall(); + return; + } + finalizeInstall(); + notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { + setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); + }); + return; + } + if (event === SSE_EVENTS.ERROR) { + if (data?.message) { + const existingError = Array.from(progressState.values()).some((state) => state?.status === STATUS.ERROR); + if (data.step || !existingError) { + let targetStep = data.step; + if (!targetStep) { + for (const candidate of INSTALLATION_STEPS) { + const state = progressState.get(candidate.id); + if (!state || state.status !== STATUS.COMPLETED) { + targetStep = candidate.id; + break; + } + } + } + handleProgress({ + step: targetStep || STEP_IDS.CONFIG_FILES, + status: STATUS.ERROR, + message: data.message, + details: data.details + }); + } + } + finalizeInstall(); + } + }); + if (activeInstall && !activeInstall.completed) { + startPolling(); + } + } catch (error) { + if (!activeInstall || activeInstall.controller.signal.aborted) { + return; + } + startPolling(); + } + }; + + const resumeInstall = async (installId) => { + const snapshot = await fetchInstallStatus(installId); + if (!snapshot) return false; + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + applySnapshot(snapshot); + startPolling(); + setUnloadGuard(true); + return true; + }; + + const resetProgressFrom = (stepId) => { + const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId); + if (index === -1) return; + INSTALLATION_STEPS.slice(index).forEach((step) => { + progressState.delete(step.id); + const row = rowsById.get(step.id); + if (row && row.parentNode) { + row.parentNode.removeChild(row); + } + rowsById.delete(step.id); + }); + }; + + const retryInstallStep = (stepId) => { + if (!stepId) return; + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + + resetProgressFrom(stepId); + + const step = getStepDefinition(stepId); + progressState.set(stepId, { + status: STATUS.IN_PROGRESS, + message: step?.inProgress || 'Retrying...' + }); + + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, STATUS.IN_PROGRESS, step.inProgress || 'Retrying...'); + } + + const installId = activeInstall?.installId || getInstallLock?.()?.installId || generateInstallId(); + storeInstallId?.(installId); + startInstallStream(installId, { retryStep: stepId }); + }; + + list.addEventListener('click', (event) => { + const consoleButton = event.target.closest('[data-install-console]'); + const retryButton = event.target.closest('[data-install-retry]'); + + if (consoleButton) { + redirectToApp(); + return; + } + + if (retryButton) { + const row = retryButton.closest('.install-row'); + const stepId = row?.dataset.step; + retryInstallStep(stepId); + } + }); + + const lock = getInstallLock?.(); + const existingInstallId = lock?.installId || getStoredInstallId?.(); + if (existingInstallId) { + resumeInstall(existingInstallId).then((resumed) => { + if (!resumed) { + clearInstallId?.(); + clearInstallLock?.(); + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + } + }); + } else { + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + } + }; + + window.InstallerStepsProgress = { + initStep5, + cleanupInstallFlow, + validateInstallRequest + }; +})(); diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js new file mode 100644 index 0000000000..ab20ccdc5e --- /dev/null +++ b/app/views/install/installer/js/modules/state.js @@ -0,0 +1,155 @@ +(() => { + const { + getBodyDataset, + isUpgradeMode, + getLockedDatabase + } = window.InstallerStepsContext || {}; + + const INSTALL_LOCK_KEY = 'appwrite-install-lock'; + const INSTALL_ID_KEY = 'appwrite-install-id'; + + const formState = { + appDomain: null, + database: null, + httpPort: null, + httpsPort: null, + emailCertificates: null, + opensslKey: null, + assistantOpenAIKey: null, + accountEmail: null, + accountPassword: null + }; + + const dispatchStateChange = (key) => { + if (!key || typeof document === 'undefined') return; + try { + document.dispatchEvent(new CustomEvent('installer:state-change', { + detail: { key, value: formState[key] } + })); + } catch (error) {} + }; + + const setStateIfEmpty = (key, value) => { + if (value === null || value === undefined || value === '') return; + if (formState[key] === null || formState[key] === undefined || formState[key] === '') { + formState[key] = value; + } + }; + + const applyBodyDefaults = () => { + const data = getBodyDataset?.() ?? {}; + setStateIfEmpty('appDomain', data.defaultAppDomain); + setStateIfEmpty('httpPort', data.defaultHttpPort); + setStateIfEmpty('httpsPort', data.defaultHttpsPort); + setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); + setStateIfEmpty('opensslKey', data.defaultSecretKey); + setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); + if (data.lockedDatabase) { + formState.database = data.lockedDatabase; + } + if (!isUpgradeMode?.()) { + setStateIfEmpty('database', data.defaultDatabase); + } + }; + + const getInstallLock = () => { + try { + const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch (error) { + return null; + } + }; + + const setInstallLock = (installId, payload) => { + const lock = { + installId, + payload: payload || null, + startedAt: Date.now() + }; + try { + sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); + } catch (error) {} + if (document.body) { + document.body.dataset.installLocked = 'true'; + } + return lock; + }; + + const clearInstallLock = () => { + try { + sessionStorage.removeItem(INSTALL_LOCK_KEY); + } catch (error) {} + if (document.body) { + delete document.body.dataset.installLocked; + } + }; + + const isInstallLocked = () => { + return Boolean(getInstallLock()); + }; + + const syncInstallLockFlag = () => { + if (!document.body) return; + if (isInstallLocked()) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const applyLockPayload = () => { + const lock = getInstallLock(); + if (!lock || !lock.payload) return; + const payload = lock.payload; + setStateIfEmpty('appDomain', payload.appDomain); + setStateIfEmpty('database', payload.database); + setStateIfEmpty('httpPort', payload.httpPort); + setStateIfEmpty('httpsPort', payload.httpsPort); + setStateIfEmpty('emailCertificates', payload.emailCertificates); + setStateIfEmpty('opensslKey', payload.opensslKey); + setStateIfEmpty('assistantOpenAIKey', payload.assistantOpenAIKey); + setStateIfEmpty('accountEmail', payload.accountEmail); + setStateIfEmpty('accountPassword', payload.accountPassword); + }; + + const getStoredInstallId = () => { + try { + return sessionStorage.getItem(INSTALL_ID_KEY); + } catch (error) { + return null; + } + }; + + const storeInstallId = (installId) => { + try { + sessionStorage.setItem(INSTALL_ID_KEY, installId); + } catch (error) {} + }; + + const clearInstallId = () => { + try { + sessionStorage.removeItem(INSTALL_ID_KEY); + } catch (error) {} + }; + + window.InstallerStepsState = { + formState, + dispatchStateChange, + setStateIfEmpty, + applyBodyDefaults, + applyLockPayload, + getInstallLock, + setInstallLock, + clearInstallLock, + isInstallLocked, + syncInstallLockFlag, + getStoredInstallId, + storeInstallId, + clearInstallId, + getLockedDatabase: getLockedDatabase || (() => '') + }; +})(); diff --git a/app/views/install/installer/js/modules/toast.js b/app/views/install/installer/js/modules/toast.js new file mode 100644 index 0000000000..5a8eb55f41 --- /dev/null +++ b/app/views/install/installer/js/modules/toast.js @@ -0,0 +1,95 @@ +(() => { + const TOAST_STACK_ID = 'installer-toast-stack'; + const DEFAULT_TIMEOUT = 5000; + const MAX_TOASTS = 3; + const ICONS = { + error: '', + close: '' + }; + + const getStack = () => document.getElementById(TOAST_STACK_ID); + + const dismissToast = (toast) => { + if (!toast) return; + if (toast.classList.contains('is-leaving')) return; + toast.classList.add('is-leaving'); + const remove = () => toast.remove(); + toast.addEventListener('transitionend', remove, { once: true }); + setTimeout(remove, 450); + }; + + const showToast = ({ + title = '', + description = '', + status = 'error', + dismissible = true, + timeout = DEFAULT_TIMEOUT + } = {}) => { + const stack = getStack(); + if (!stack) return; + const visibleToasts = Array.from( + stack.querySelectorAll('.installer-toast:not(.is-leaving)') + ); + if (visibleToasts.length >= MAX_TOASTS) { + dismissToast(visibleToasts[0]); + } + + const toast = document.createElement('div'); + toast.className = 'installer-toast is-entering'; + toast.dataset.status = status; + toast.setAttribute('role', status === 'error' ? 'alert' : 'status'); + + const content = document.createElement('div'); + content.className = 'installer-toast-content'; + + const icon = document.createElement('span'); + icon.className = 'installer-toast-icon'; + icon.dataset.status = status; + icon.innerHTML = ICONS.error; + content.appendChild(icon); + + const body = document.createElement('section'); + body.className = 'installer-toast-body'; + + if (title) { + const titleNode = document.createElement('p'); + titleNode.className = 'installer-toast-title typography-text-m-500'; + titleNode.textContent = title; + body.appendChild(titleNode); + } + + if (description) { + const descNode = document.createElement('p'); + descNode.className = 'installer-toast-description typography-text-m-400'; + descNode.textContent = description; + body.appendChild(descNode); + } + + content.appendChild(body); + toast.appendChild(content); + + if (dismissible) { + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'installer-toast-close'; + close.setAttribute('aria-label', 'Dismiss notification'); + close.innerHTML = ICONS.close; + close.addEventListener('click', () => dismissToast(toast)); + toast.appendChild(close); + } + + stack.appendChild(toast); + toast.getBoundingClientRect(); + requestAnimationFrame(() => { + toast.classList.remove('is-entering'); + }); + + if (timeout > 0) { + setTimeout(() => dismissToast(toast), timeout); + } + }; + + window.InstallerToast = Object.freeze({ + showToast + }); +})(); diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js new file mode 100644 index 0000000000..19aa3b63a0 --- /dev/null +++ b/app/views/install/installer/js/modules/ui.js @@ -0,0 +1,278 @@ +(() => { + const { TIMINGS } = window.InstallerStepsContext || {}; + const { formState } = window.InstallerStepsState || {}; + + const clearFieldErrors = (root) => { + if (!root) return; + root.querySelectorAll('.field-error').forEach((node) => { + node.classList.remove('is-visible'); + }); + root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => { + node.classList.remove('is-error'); + }); + root.querySelectorAll('.field-helper').forEach((helper) => { + helper.style.display = ''; + }); + }; + + const setFieldError = (input, message) => { + if (!input) return; + const group = input.closest('.input-group'); + if (!group) return; + let error = group.querySelector('.field-error'); + let errorText = error?.querySelector('.field-error-text'); + const hasSameMessage = Boolean(errorText && errorText.textContent === message); + const alreadyVisible = Boolean(error && error.classList.contains('is-visible')); + + if (hasSameMessage && alreadyVisible) { + return; + } + + if (!error) { + const template = document.getElementById('field-error-template'); + if (template && template.content) { + const fragment = template.content.cloneNode(true); + error = fragment.querySelector('.field-error'); + group.appendChild(fragment); + } + errorText = error?.querySelector('.field-error-text'); + } + if (errorText) { + errorText.textContent = message; + } + + if (!alreadyVisible) { + requestAnimationFrame(() => { + error.classList.add('is-visible'); + }); + } + + input.classList.add('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.add('is-error'); + } + const helper = group.querySelector('.field-helper'); + if (helper) { + helper.style.display = 'none'; + } + }; + + const bindErrorClear = (input) => { + if (!input) return; + const handler = () => { + const group = input.closest('.input-group'); + const error = group?.querySelector('.field-error'); + if (error) { + error.classList.remove('is-visible'); + } + input.classList.remove('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.remove('is-error'); + } + const helper = group?.querySelector('.field-helper'); + if (helper) { + helper.style.display = ''; + } + }; + input.addEventListener('input', handler); + input.addEventListener('change', handler); + }; + + const toDatabaseLabel = (value) => { + if (!value) return ''; + return value.toLowerCase() === 'mariadb' ? 'MariaDB' : 'MongoDB'; + }; + + const updateDatabaseSelection = (radio, root) => { + if (!radio || !root) return; + const allOptions = root.querySelectorAll('.selector-card'); + allOptions.forEach((option) => option.classList.remove('selected')); + const selectedOption = radio.closest('.selector-card'); + if (selectedOption) { + selectedOption.classList.add('selected'); + } + }; + + const syncResetButton = (input, button) => { + const defaultValue = input.dataset.default ?? ''; + button.disabled = input.value === defaultValue; + }; + + const setupResetButtons = (root) => { + const inputs = root.querySelectorAll('.input-field[data-default]'); + inputs.forEach((input) => { + const button = root.querySelector(`[data-reset-target="${input.id}"]`); + if (!button) return; + + syncResetButton(input, button); + + input.addEventListener('input', () => syncResetButton(input, button)); + button.addEventListener('click', () => { + input.value = input.dataset.default ?? ''; + syncResetButton(input, button); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }); + }; + + const toggleAccordion = (button) => { + const content = button.nextElementSibling; + const icon = button.querySelector('.accordion-chevron'); + const isOpen = button.classList.contains('is-open'); + + button.classList.toggle('is-open', !isOpen); + button.setAttribute('aria-expanded', String(!isOpen)); + + if (content) { + if (!isOpen) { + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } else { + content.style.maxHeight = '0px'; + content.classList.remove('open'); + } + } + + if (icon) { + icon.setAttribute('data-open', String(!isOpen)); + } + }; + + const setupAccordion = (root) => { + const buttons = root.querySelectorAll('.accordion-toggle'); + buttons.forEach((button) => { + button.addEventListener('click', () => toggleAccordion(button)); + }); + }; + + const openAccordion = (root) => { + const toggle = root.querySelector('.accordion-toggle'); + const content = root.querySelector('.accordion-content'); + if (!toggle || !content) return; + if (!toggle.classList.contains('is-open')) { + toggle.classList.add('is-open'); + toggle.setAttribute('aria-expanded', 'true'); + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } + }; + + const disableControls = (root) => { + const inputs = root.querySelectorAll('input, select, textarea'); + inputs.forEach((input) => { + if (input.type === 'radio' || input.type === 'checkbox') { + input.disabled = true; + } else { + input.readOnly = true; + input.setAttribute('aria-disabled', 'true'); + } + }); + + const buttons = root.querySelectorAll('button'); + buttons.forEach((button) => { + if (button.matches('[data-copy-target]')) return; + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + }); + + root.classList.add('is-locked'); + }; + + const generateSecretKey = () => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const copyToClipboard = (value, input) => { + if (!value) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value); + return; + } + if (input) { + input.select(); + document.execCommand('copy'); + input.setSelectionRange(0, 0); + return; + } + const textArea = document.createElement('textarea'); + textArea.value = value; + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + } catch (error) {} finally { + document.body.removeChild(textArea); + } + }; + + const setTooltipText = (wrapper, message) => { + if (!wrapper) return; + const tooltip = wrapper.querySelector('.tooltip'); + if (tooltip && message) { + tooltip.textContent = message; + } + }; + + const resetTooltipText = (wrapper) => { + if (!wrapper) return; + const defaultText = wrapper.dataset.tooltipDefault; + if (!defaultText) return; + setTooltipText(wrapper, defaultText); + }; + + const updateReviewSummary = (root) => { + if (!root) return; + const valueNodes = root.querySelectorAll('[data-review-value]'); + valueNodes.forEach((node) => { + const key = node.dataset.reviewValue; + if (!key) return; + let value = formState?.[key]; + if (key === 'database') { + value = toDatabaseLabel(formState?.database); + } + if (value) { + node.textContent = value; + } + }); + + const badge = root.querySelector('[data-review-badge]'); + if (badge) { + const hasKey = Boolean((formState?.opensslKey || '').trim()); + badge.textContent = hasKey ? 'Generated' : 'Missing'; + badge.classList.remove('badge-success', 'badge-warning'); + badge.classList.add(hasKey ? 'badge-success' : 'badge-warning'); + } + + const assistantBadge = root.querySelector('[data-review-assistant-badge]'); + if (assistantBadge) { + const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim()); + assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled'; + assistantBadge.classList.remove('badge-success', 'badge-neutral'); + assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral'); + } + }; + + window.InstallerStepsUI = { + clearFieldErrors, + setFieldError, + bindErrorClear, + toDatabaseLabel, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + }; +})(); diff --git a/app/views/install/installer/js/modules/validation.js b/app/views/install/installer/js/modules/validation.js new file mode 100644 index 0000000000..13ab60ef4e --- /dev/null +++ b/app/views/install/installer/js/modules/validation.js @@ -0,0 +1,117 @@ +(() => { + const isValidEmail = (email) => { + if (!email) return false; + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); + }; + + const isValidPort = (value) => { + const numeric = Number(value); + if (!Number.isInteger(numeric)) return false; + return numeric >= 1 && numeric <= 65535; + }; + + const isValidPassword = (value) => { + if (!value) return false; + return value.length >= 8 && /\S/.test(value); + }; + + const isValidIPv4 = (host) => { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false; + return host.split('.').every((part) => { + const num = Number(part); + return num >= 0 && num <= 255; + }); + }; + + const isValidIPv6 = (host) => { + try { + const url = new URL(`http://[${host}]`); + return url.hostname.toLowerCase() === host.toLowerCase(); + } catch (error) { + return false; + } + }; + + const isValidHostnameLabel = (label) => { + if (!label || label.length > 63) return false; + if (label.startsWith('-') || label.endsWith('-')) return false; + return /^[a-zA-Z0-9-]+$/.test(label); + }; + + const isValidDomain = (host) => { + if (host.length > 253) return false; + const labels = host.split('.'); + return labels.every((label) => isValidHostnameLabel(label)); + }; + + const isValidHost = (host) => { + if (host === 'localhost') return true; + if (isValidIPv4(host)) return true; + if (isValidIPv6(host)) return true; + return isValidDomain(host); + }; + + const isValidHostnameInput = (value) => { + if (!value) return false; + const trimmed = value.trim(); + if (!trimmed) return false; + + let host = trimmed; + let port = null; + + if (trimmed.startsWith('[')) { + const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (!match) return false; + host = match[1] || ''; + port = match[2] || null; + } else { + const parts = trimmed.split(':'); + if (parts.length > 2) return false; + if (parts.length === 2) { + host = parts[0]; + port = parts[1]; + } + } + + if (port !== null && port !== '' && !isValidPort(port)) { + return false; + } + + return isValidHost(host); + }; + + const extractHostname = (value) => { + if (!value) return ''; + const trimmed = value.trim(); + if (trimmed.startsWith('[')) { + const end = trimmed.indexOf(']'); + if (end !== -1) { + return trimmed.slice(1, end); + } + return trimmed; + } + const colonCount = (trimmed.match(/:/g) || []).length; + if (colonCount === 1) { + return trimmed.split(':')[0]; + } + return trimmed; + }; + + const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); + + const isLocalHost = (host) => { + if (!host) return false; + const normalized = host.toLowerCase(); + return LOCAL_HOSTS.has(normalized); + }; + + window.InstallerStepsValidation = { + isValidEmail, + isValidPort, + isValidPassword, + isValidHostnameInput, + extractHostname, + isLocalHost + }; +})(); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js new file mode 100644 index 0000000000..77c4600227 --- /dev/null +++ b/app/views/install/installer/js/steps.js @@ -0,0 +1,434 @@ +(() => { + const Context = window.InstallerStepsContext || {}; + const State = window.InstallerStepsState || {}; + const Validation = window.InstallerStepsValidation || {}; + const UI = window.InstallerStepsUI || {}; + const Progress = window.InstallerStepsProgress || {}; + const Tooltips = window.InstallerTooltips || null; + + const { + INSTALLATION_STEPS, + clampStep + } = Context; + + const { + formState, + dispatchStateChange, + applyBodyDefaults, + applyLockPayload, + clearInstallLock, + clearInstallId, + isInstallLocked, + syncInstallLockFlag, + getInstallLock, + getLockedDatabase + } = State; + + const { + isValidEmail, + isValidPort, + isValidHostnameInput, + isValidPassword + } = Validation; + + const { + clearFieldErrors, + setFieldError, + bindErrorClear, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + } = UI; + + let reviewListener = null; + + const bindInputToState = (input, key) => { + if (!input) return; + const update = () => { + formState[key] = input.value; + dispatchStateChange?.(key); + }; + input.addEventListener('input', update); + input.addEventListener('change', update); + update(); + }; + + const lockDatabaseSelection = (root, lockedDatabase) => { + if (lockedDatabase) { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + const isLockedChoice = radio.value === lockedDatabase; + const card = radio.closest('.selector-card'); + radio.disabled = !isLockedChoice; + if (card) { + card.classList.toggle('is-disabled', !isLockedChoice); + } + if (isLockedChoice) { + radio.checked = true; + updateDatabaseSelection?.(radio, root); + } + }); + } + }; + + const bindDatabaseSelection = (root) => { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + radio.addEventListener('change', () => { + formState.database = radio.value; + updateDatabaseSelection?.(radio, root); + }); + }); + }; + + const hydrateStep1State = (root) => { + State.setStateIfEmpty?.('appDomain', root.querySelector('#hostname')?.value); + State.setStateIfEmpty?.('database', root.querySelector('input[name="database"]:checked')?.value); + State.setStateIfEmpty?.('httpPort', root.querySelector('#http-port')?.value); + State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value); + State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value); + State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); + }; + + const applyStep1State = (root) => { + const hostname = root.querySelector('#hostname'); + if (hostname && formState.appDomain) hostname.value = formState.appDomain; + + const httpPort = root.querySelector('#http-port'); + if (httpPort && formState.httpPort) httpPort.value = formState.httpPort; + + const httpsPort = root.querySelector('#https-port'); + if (httpsPort && formState.httpsPort) httpsPort.value = formState.httpsPort; + + const sslEmail = root.querySelector('#ssl-email'); + if (sslEmail && formState.emailCertificates) sslEmail.value = formState.emailCertificates; + + const assistantKey = root.querySelector('#assistant-openai-key'); + if (assistantKey && formState.assistantOpenAIKey) { + assistantKey.value = formState.assistantOpenAIKey; + } + + if (formState.database) { + const radio = root.querySelector(`input[name="database"][value="${formState.database}"]`); + if (radio) { + radio.checked = true; + updateDatabaseSelection?.(radio, root); + } + } + }; + + const initStep1 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep1State(root); + applyStep1State(root); + + if (isInstallLocked?.()) { + openAccordion?.(root); + disableControls?.(root); + return; + } + + const lockedDatabase = getLockedDatabase?.() || ''; + if (lockedDatabase) { + lockDatabaseSelection(root, lockedDatabase); + } else { + bindDatabaseSelection(root); + } + + const hostname = root.querySelector('#hostname'); + const httpPort = root.querySelector('#http-port'); + const httpsPort = root.querySelector('#https-port'); + const sslEmail = root.querySelector('#ssl-email'); + const assistantKey = root.querySelector('#assistant-openai-key'); + + bindInputToState(hostname, 'appDomain'); + bindInputToState(httpPort, 'httpPort'); + bindInputToState(httpsPort, 'httpsPort'); + bindInputToState(sslEmail, 'emailCertificates'); + bindInputToState(assistantKey, 'assistantOpenAIKey'); + + bindErrorClear?.(hostname); + bindErrorClear?.(httpPort); + bindErrorClear?.(httpsPort); + bindErrorClear?.(sslEmail); + bindErrorClear?.(assistantKey); + + const checked = root.querySelector('input[name="database"]:checked'); + if (checked) { + updateDatabaseSelection?.(checked, root); + } + + setupResetButtons?.(root); + setupAccordion?.(root); + Tooltips?.setupTooltipPortals?.(root); + }; + + const hydrateStep2State = (root) => { + const value = root.querySelector('#secret-key')?.value; + if (formState.opensslKey) return; + if (value) { + formState.opensslKey = value; + } + }; + + const applyStep2State = (root) => { + const input = root.querySelector('#secret-key'); + if (input && formState.opensslKey) { + input.value = formState.opensslKey; + } + }; + + const initStep2 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep2State(root); + if (!formState.opensslKey || !formState.opensslKey.trim()) { + formState.opensslKey = generateSecretKey?.(); + dispatchStateChange?.('opensslKey'); + } + applyStep2State(root); + + const input = root.querySelector('#secret-key'); + if (input) { + bindInputToState(input, 'opensslKey'); + bindErrorClear?.(input); + } + + const copyButton = root.querySelector('[data-copy-target]'); + const tooltipWrapper = copyButton?.closest('.tooltip-wrapper'); + + if (tooltipWrapper) { + tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText?.(tooltipWrapper)); + tooltipWrapper.addEventListener('focusin', () => resetTooltipText?.(tooltipWrapper)); + } + + if (copyButton) { + copyButton.addEventListener('click', () => { + const targetId = copyButton.getAttribute('data-copy-target'); + const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; + const value = targetInput?.value || ''; + copyToClipboard?.(value, targetInput); + copyButton.blur(); + + if (tooltipWrapper) { + const successText = tooltipWrapper.dataset.tooltipSuccess || 'Copied'; + setTooltipText?.(tooltipWrapper, successText); + } + }); + } + + const regenerateButton = root.querySelector('[data-regenerate-target]'); + if (regenerateButton && !isInstallLocked?.()) { + regenerateButton.addEventListener('click', () => { + const targetId = regenerateButton.getAttribute('data-regenerate-target'); + const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; + if (!targetInput) return; + regenerateButton.classList.remove('is-rotating'); + void regenerateButton.offsetWidth; + regenerateButton.classList.add('is-rotating'); + const handleAnimationEnd = () => { + regenerateButton.classList.remove('is-rotating'); + }; + regenerateButton.addEventListener('animationend', handleAnimationEnd, { once: true }); + targetInput.value = generateSecretKey?.(); + targetInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const hydrateStep3State = (root) => { + State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value); + State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value); + }; + + const applyStep3State = (root) => { + const email = root.querySelector('#account-email'); + if (email && formState.accountEmail) email.value = formState.accountEmail; + + const password = root.querySelector('#account-password'); + if (password && formState.accountPassword) password.value = formState.accountPassword; + }; + + const initStep3 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep3State(root); + applyStep3State(root); + + const email = root.querySelector('#account-email'); + const password = root.querySelector('#account-password'); + const passwordToggle = root.querySelector('[data-password-toggle="account-password"]'); + + bindInputToState(email, 'accountEmail'); + bindInputToState(password, 'accountPassword'); + + bindErrorClear?.(email); + bindErrorClear?.(password); + + if (password && passwordToggle) { + passwordToggle.addEventListener('click', () => { + const isVisible = passwordToggle.classList.toggle('is-visible'); + password.type = isVisible ? 'text' : 'password'; + passwordToggle.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password'); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const initStep4 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + updateReviewSummary?.(root); + if (reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + } + reviewListener = () => updateReviewSummary?.(root); + document.addEventListener('installer:state-change', reviewListener); + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const initStep = (step, container) => { + if (!container) return; + const root = container.querySelector('.step-layout') || container; + const normalized = clampStep?.(step) ?? 1; + Tooltips?.cleanupTooltipPortals?.(); + if (normalized !== 4 && reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + reviewListener = null; + } + if (normalized !== 5) { + Progress.cleanupInstallFlow?.(); + } + if (normalized === 1) initStep1(root); + if (normalized === 2) initStep2(root); + if (normalized === 3) initStep3(root); + if (normalized === 4) initStep4(root); + if (normalized === 5) Progress.initStep5?.(root); + }; + + window.InstallerSteps = { + initStep1, + initStep2, + initStep3, + initStep4, + initStep5: Progress.initStep5, + installationSteps: INSTALLATION_STEPS || [], + isInstallLocked, + getInstallLock, + clearInstallLock, + initStep, + validateStep: (step, container) => { + const root = container?.querySelector('.step-layout') || container; + const normalized = clampStep?.(step) ?? 1; + if (normalized === 1) { + clearFieldErrors?.(root); + let valid = true; + const hostname = root?.querySelector('#hostname'); + const httpPort = root?.querySelector('#http-port'); + const httpsPort = root?.querySelector('#https-port'); + const sslEmail = root?.querySelector('#ssl-email'); + + if (!hostname || !hostname.value.trim()) { + setFieldError?.(hostname, 'Please enter your Appwrite hostname'); + valid = false; + } else if (!isValidHostnameInput?.(hostname.value.trim())) { + setFieldError?.(hostname, 'Please enter a valid hostname'); + valid = false; + } + + const parsePort = (input, label) => { + const value = input?.value; + if (!value || !isValidPort?.(value)) { + setFieldError?.(input, `Please enter a valid ${label} port (1-65535)`); + return false; + } + return true; + }; + + if (!parsePort(httpPort, 'HTTP')) valid = false; + if (!parsePort(httpsPort, 'HTTPS')) valid = false; + + if (!sslEmail || !sslEmail.value.trim()) { + setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates'); + valid = false; + } else if (!isValidEmail?.(sslEmail.value.trim())) { + setFieldError?.(sslEmail, 'Please enter a valid email address'); + valid = false; + } + + if (!valid) { + openAccordion?.(root); + } + + return valid; + } + + if (normalized === 2) { + clearFieldErrors?.(root); + const secretKey = root?.querySelector('#secret-key'); + const secretValue = secretKey?.value.trim() || ''; + if (!secretKey || !secretValue) { + setFieldError?.(secretKey, 'Please enter or generate a secret API key'); + return false; + } + if (secretValue.length > 64) { + setFieldError?.(secretKey, 'Secret API key must be 1-64 characters'); + return false; + } + } + + if (normalized === 3) { + clearFieldErrors?.(root); + let valid = true; + const email = root?.querySelector('#account-email'); + const password = root?.querySelector('#account-password'); + + if (!email || !email.value.trim()) { + setFieldError?.(email, 'This field is required'); + valid = false; + } else if (!isValidEmail?.(email.value.trim())) { + setFieldError?.(email, 'Please enter a valid email address'); + valid = false; + } + + const passwordValue = password?.value ?? ''; + if (!password || !/\S/.test(passwordValue)) { + setFieldError?.(password, 'This field is required'); + valid = false; + } else if (!isValidPassword?.(passwordValue)) { + setFieldError?.(password, 'Password must be at least 8 characters long'); + valid = false; + } + + return valid; + } + + return true; + } + }; +})(); diff --git a/app/views/install/installer/js/tooltips.js b/app/views/install/installer/js/tooltips.js new file mode 100644 index 0000000000..96b637b9a3 --- /dev/null +++ b/app/views/install/installer/js/tooltips.js @@ -0,0 +1,77 @@ +(() => { + const tooltipPortals = new Set(); + + const positionTooltipPortal = (tooltip, anchor) => { + if (!tooltip || !anchor) return; + const rect = anchor.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const offset = Number(tooltip.dataset.tooltipOffset || 6); + const padding = 8; + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding)); + const top = rect.bottom + offset; + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + }; + + const attachTooltipPortal = (tooltip) => { + if (!tooltip || tooltip.dataset.portalInitialized === 'true') return; + const anchor = tooltip.parentElement; + if (!anchor) return; + + tooltip.dataset.portalInitialized = 'true'; + tooltip.classList.add('tooltip-portal'); + document.body.appendChild(tooltip); + + const show = () => { + tooltip.classList.add('is-open'); + positionTooltipPortal(tooltip, anchor); + }; + const hide = () => { + tooltip.classList.remove('is-open'); + }; + const refresh = () => { + if (tooltip.classList.contains('is-open')) { + positionTooltipPortal(tooltip, anchor); + } + }; + + anchor.addEventListener('mouseenter', show); + anchor.addEventListener('mouseleave', hide); + anchor.addEventListener('focusin', show); + anchor.addEventListener('focusout', hide); + window.addEventListener('scroll', refresh, true); + window.addEventListener('resize', refresh); + + tooltipPortals.add({ + tooltip, + cleanup: () => { + anchor.removeEventListener('mouseenter', show); + anchor.removeEventListener('mouseleave', hide); + anchor.removeEventListener('focusin', show); + anchor.removeEventListener('focusout', hide); + window.removeEventListener('scroll', refresh, true); + window.removeEventListener('resize', refresh); + if (tooltip.parentElement) { + tooltip.parentElement.removeChild(tooltip); + } + } + }); + }; + + const setupTooltipPortals = (root) => { + if (!root) return; + const portalTooltips = root.querySelectorAll('.tooltip[data-tooltip-portal]'); + portalTooltips.forEach((tooltip) => attachTooltipPortal(tooltip)); + }; + + const cleanupTooltipPortals = () => { + tooltipPortals.forEach((entry) => entry.cleanup()); + tooltipPortals.clear(); + }; + + window.InstallerTooltips = { + setupTooltipPortals, + cleanupTooltipPortals + }; +})(); diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml new file mode 100644 index 0000000000..0b331b8e02 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -0,0 +1,171 @@ + +
+
+
+

+

+ +

+
+ +
+
+ + +
+ +
+ +
+ + + +
+
+ +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+
+
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-2.phtml b/app/views/install/installer/templates/steps/step-2.phtml new file mode 100644 index 0000000000..9b7eed11b1 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-2.phtml @@ -0,0 +1,58 @@ + +
+
+
+

Secure your app

+

+ +

+
+ +
+
+
+ +
+
Save your key
+
You won't be able to see this key again. Copy it somewhere safe before continuing.
+
+
+
+ +
+
+ +
+ +
+ + + Copy + +
+
+
+ +
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-3.phtml b/app/views/install/installer/templates/steps/step-3.phtml new file mode 100644 index 0000000000..8eaf0e044b --- /dev/null +++ b/app/views/install/installer/templates/steps/step-3.phtml @@ -0,0 +1,61 @@ + +
+
+
+

Create your account

+

+ Set up the email and password for your Appwrite account. You can use these + credentials to sign in later. +

+
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+ + + + Password must be at least 8 characters long +
+
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml new file mode 100644 index 0000000000..4886504eee --- /dev/null +++ b/app/views/install/installer/templates/steps/step-4.phtml @@ -0,0 +1,72 @@ + +
+
+
+

+

+ +

+
+ +
+
+
+
+
+ +
+
Hostname
+
+
+
+ +
+
Database
+
+
+
+ +
+
HTTP port
+
+
+
+ +
+
HTTPS port
+
+
+
+ +
+
SSL certificate email
+
+
+ Disabled +
Appwrite Assistant
+
+
+ + + +
Secret API key
+
+
+
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml new file mode 100644 index 0000000000..8fa810b259 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-5.phtml @@ -0,0 +1,53 @@ + +
+
+
+
+
+ +
+
+
+
+
+ + +
diff --git a/composer.json b/composer.json index 67aea3d470..83db6e1024 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,10 @@ "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", "bench": "vendor/bin/phpbench run --report=benchmark", - "check": "./vendor/bin/phpstan analyse -c phpstan.neon" + "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean", + "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker", + "installer:dev-upgrade": "docker compose build && php src/Appwrite/Platform/Installer/Server.php --docker --upgrade" }, "autoload": { "psr-4": { diff --git a/docs/WEB_INSTALLER.md b/docs/WEB_INSTALLER.md new file mode 100644 index 0000000000..436bd22a50 --- /dev/null +++ b/docs/WEB_INSTALLER.md @@ -0,0 +1,131 @@ +# Appwrite Web Installer + +The Appwrite Web Installer provides a modern, user-friendly web interface for installing Appwrite. It runs in a +temporary Docker container with its own web server before the Appwrite stack is created. + +## Architecture + +The web installer runs as part of the installation task in a lightweight PHP built-in web server: + +``` +docker run (temporary container) + └── Server.php + ├── PHP built-in server (0.0.0.0:20080) + ├── HttpHandler (routing, CSRF, SSE) + ├── State (progress tracking) + └── Config (installation settings) +``` + +## Features + +- Retry failed steps with payload validation +- Step-by-step guided installation with real-time validation +- Progress tracking with Server-Sent Events and polling fallback + +## Usage + +### Docker + +```bash +docker run -it --rm \ + --name appwrite-installer \ + -p 20080:20080 \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ + --entrypoint="install" \ + appwrite/appwrite: +``` + +Then open http://localhost:20080 in your browser. + +### Composer (Development) + +```bash +composer installer:dev # Start web installer in Docker +composer installer:dev-upgrade # Start web installer in upgrade mode +composer installer:clean # Clean up installer containers and temp files +``` + +### CLI Mode + +```bash +docker run -it --rm \ + --name appwrite-installer \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ + --entrypoint="install" \ + appwrite/appwrite: \ + --interactive N +``` + +## Files + +### Frontend + +- `app/views/install/installer.phtml` - Main layout +- `app/views/install/installer/` - UI components (templates, CSS, JS, icons) + +### Backend + +- `src/Appwrite/Platform/Installer/**/*.php` - Installer backend (Server, HttpHandler, State, Config) +- `src/Appwrite/Platform/Tasks/Install.php` - Installation task execution +- `app/views/install/*.phtml` - Docker Compose and env templates + +## Technical Details + +### Endpoints + +- `GET /` - Installer UI +- `POST /install` - Start installation (SSE stream or JSON) +- `GET /install/status?installId=...` - Progress polling +- `POST /install/validate` - CSRF validation +- `POST /install/complete` - Notify completion + +### URL Parameters + +- `?step=1..5` - Jump to specific installation step + +### Installation Flow + +1. User submits form → CSRF validation +2. Acquire global lock (prevents concurrent installs) +3. Stream progress via SSE (or poll if SSE fails) +4. Generate `docker-compose.yml` and `.env` +5. Run `docker compose up -d` +6. Redirect to console, shutdown server + +## Security + +- **CSRF**: Cookie tokens with timing-safe comparison +- **CSP**: Strict headers prevent XSS +- **Validation**: Ports (1-65535), emails, domains, env var names +- **Escaping**: All shell commands use `escapeshellarg()` +- **Path protection**: `realpath()` + `str_starts_with()` checks +- **File permissions**: Config files created with mode 0600 +- **Global lock**: File-based with exception-safe cleanup + +## Troubleshooting + +### ERR_EMPTY_RESPONSE + +Server binds to `0.0.0.0` (not `localhost`) for Docker port mapping. If `localhost` resolves to IPv6 `::1`, connection +will fail from host. + +### Can't access localhost:20080 + +Ensure `-p 20080:20080` is in docker run command. Try `http://127.0.0.1:20080` instead. + +### Installation fails + +- Check Docker is running +- Verify write permissions for appwrite directory +- Check browser console for errors +- Review Docker logs: `docker logs appwrite-installer` + +## Support + +For issues or questions: + +- [GitHub Issues](https://github.com/appwrite/appwrite/issues) +- [Discord Community](https://appwrite.io/discord) +- [Documentation](https://appwrite.io/docs) diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh new file mode 100644 index 0000000000..36b8b930dd --- /dev/null +++ b/mongo-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +# Start MongoDB in the background +mongod --replSet rs0 --bind_ip_all & + +# Wait for MongoDB to start +echo "Waiting for MongoDB to start..." +until mongosh --eval "print('MongoDB is ready')" > /dev/null 2>&1; do + sleep 1 +done + +# Initialize replica set if not already initialized +echo "Initializing replica set..." +mongosh --eval " +try { + rs.status(); + print('Replica set already initialized'); +} catch (e) { + rs.initiate({ + _id: 'rs0', + members: [{ _id: 0, host: 'localhost:27017' }] + }); + print('Replica set initialized'); +} +" + +# Wait for replica set to be ready +echo "Waiting for replica set to be ready..." +until mongosh --eval "rs.status().ok" > /dev/null 2>&1; do + sleep 1 +done + +echo "MongoDB replica set is ready" + +# Keep the container running by waiting on the MongoDB process +wait diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..f0f626965c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "@appwrite.io/repo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@appwrite.io/repo", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index 1e2b1be430..aa4371ab22 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,20 @@ "files": [ "docs/examples", "app/config/specs" - ] + ], + "version": "1.0.0", + "description": "> We just announced Timestamp Overrides for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-timestamp-overrides)", + "main": "index.js", + "directories": { + "doc": "docs", + "test": "tests" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/appwrite/appwrite/issues" + }, + "homepage": "https://github.com/appwrite/appwrite#readme" } diff --git a/public/images/logos/mariadb.svg b/public/images/logos/mariadb.svg new file mode 100644 index 0000000000..0d00509e3d --- /dev/null +++ b/public/images/logos/mariadb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/logos/mongodb.svg b/public/images/logos/mongodb.svg new file mode 100644 index 0000000000..54403d5284 --- /dev/null +++ b/public/images/logos/mongodb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Appwrite/Platform/Installer/HttpHandler.php b/src/Appwrite/Platform/Installer/HttpHandler.php new file mode 100644 index 0000000000..0f610cd9c6 --- /dev/null +++ b/src/Appwrite/Platform/Installer/HttpHandler.php @@ -0,0 +1,687 @@ +paths = $paths; + $this->state = $state; + $this->config = $state->buildConfig(); + } + + public function handleRequest(): void + { + $uri = urldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); + if ($uri === '') { + $uri = '/'; + } + + if ($this->handleStaticFiles($uri)) { + return; + } + + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $handled = match (true) { + $method === 'GET' && $uri === '/install/status' => $this->handleStatusRequest($uri), + $method === 'POST' && $uri === '/install/validate' => $this->handleValidateRequest($uri), + $method === 'POST' && $uri === '/install/complete' => $this->handleCompleteRequest($uri), + $method === 'POST' && $uri === '/install' => $this->handleInstallRequest($uri), + $uri === '/' => $this->handleInstallerView($uri), + default => false, + }; + + if ($handled) { + return; + } + + http_response_code(self::HTTP_NOT_FOUND); + echo '404 Not Found'; + } + + private function handleStaticFiles(string $uri): bool + { + if ($uri === '/' || $uri === '') { + return false; + } + + $appViewsPath = realpath($this->paths['views']); + $publicPath = realpath($this->paths['public']); + + if (!$appViewsPath || !$publicPath) { + return false; + } + + $filePath = null; + $extension = strtolower(pathinfo($uri, PATHINFO_EXTENSION)); + if ($extension) { + $candidate = realpath($appViewsPath . $uri); + if ($candidate && str_starts_with($candidate, $appViewsPath . DIRECTORY_SEPARATOR)) { + $filePath = $candidate; + } + if (!$filePath) { + $candidate = realpath($publicPath . $uri); + if ($candidate && str_starts_with($candidate, $publicPath . DIRECTORY_SEPARATOR)) { + $filePath = $candidate; + } + } + } + + if ($filePath && is_file($filePath)) { + $mimeTypes = [ + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + ]; + + if (!isset($mimeTypes[$extension])) { + return false; + } + + $contentType = $mimeTypes[$extension]; + header('Content-Type: ' . $contentType); + header('Content-Length: ' . filesize($filePath)); + readfile($filePath); + return true; + } + + return false; + } + + private function handleStatusRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'GET' || $uri !== '/install/status') { + return false; + } + + header('Content-Type: application/json'); + + $installId = $this->state->sanitizeInstallId($_GET['installId'] ?? ''); + if ($installId === '') { + http_response_code(self::HTTP_BAD_REQUEST); + echo json_encode(['success' => false, 'message' => 'Missing installId']); + return true; + } + + $path = $this->state->progressFilePath($installId); + if (!file_exists($path)) { + http_response_code(self::HTTP_NOT_FOUND); + echo json_encode(['success' => false, 'message' => 'Install not found']); + return true; + } + + $data = $this->state->readProgressFile($installId); + if ($this->hasPayload($data)) { + unset($data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey']); + } + echo json_encode(['success' => true, 'progress' => $data]); + return true; + } + + private function handleValidateRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install/validate') { + return false; + } + + header('Content-Type: application/json'); + $this->validateCsrf(false); + echo json_encode(['success' => true]); + return true; + } + + private function handleCompleteRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install/complete') { + return false; + } + + header('Content-Type: application/json'); + $this->validateCsrf(false); + + $input = json_decode(file_get_contents('php://input'), true); + if (!is_array($input)) { + $input = []; + } + $installId = $this->state->sanitizeInstallId($input['installId'] ?? ''); + $sessionId = is_string($input['sessionId'] ?? null) ? $input['sessionId'] : null; + $sessionSecret = is_string($input['sessionSecret'] ?? null) ? $input['sessionSecret'] : null; + $sessionExpire = is_string($input['sessionExpire'] ?? null) ? $input['sessionExpire'] : null; + + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } + + @touch(Server::INSTALLER_COMPLETE_FILE); + + if ($sessionSecret) { + $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); + $sameSite = $isHttps ? 'None' : 'Lax'; + $expires = 0; + if ($sessionExpire) { + $timestamp = strtotime($sessionExpire); + if ($timestamp !== false) { + $expires = $timestamp; + } + } + $cookieOptions = [ + 'expires' => $expires, + 'path' => '/', + 'secure' => $isHttps, + 'httponly' => true, + 'samesite' => $sameSite, + ]; + setcookie('a_session_console', $sessionSecret, $cookieOptions); + setcookie('a_session_console_legacy', $sessionSecret, $cookieOptions); + if ($sessionId) { + header('X-Appwrite-Session: ' . $sessionId); + } + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + + echo json_encode(['success' => true]); + + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } + + if (function_exists('posix_getpid')) { + $pid = posix_getpid(); + if ($pid) { + $command = 'sh -c ' . escapeshellarg("sleep " . self::SHUTDOWN_DELAY_SECONDS . "; kill $pid >/dev/null 2>&1"); + @exec($command . ' >/dev/null 2>&1 &'); + } + } + + return true; + } + + private function handleInstallRequest(string $uri): bool + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install') { + return false; + } + + $acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? ''; + $acceptsStream = stripos($acceptHeader, 'text/event-stream') !== false; + $wantsStream = $acceptsStream; + + if ($wantsStream) { + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + @ini_set('output_buffering', 'off'); + @ini_set('zlib.output_compression', '0'); + @ini_set('implicit_flush', '1'); + if (function_exists('ob_get_level')) { + while (ob_get_level() > 0) { + @ob_end_flush(); + } + } + @ob_implicit_flush(true); + + echo "event: ping\n"; + echo "data: {\"time\":" . time() . "}\n\n"; + @ob_flush(); + @flush(); + } else { + header('Content-Type: application/json'); + } + + $this->validateCsrf($wantsStream); + + $rawBody = file_get_contents('php://input'); + $input = json_decode($rawBody, true); + + if (!is_array($input)) { + http_response_code(self::HTTP_BAD_REQUEST); + echo json_encode(['success' => false, 'message' => 'Invalid request']); + return true; + } + + $appDomain = trim((string) ($input['appDomain'] ?? '')); + if ($appDomain === '' || !$this->state->isValidAppDomainInput($appDomain)) { + $this->respondBadRequest('Please enter a valid hostname', $wantsStream); + } + $input['appDomain'] = $appDomain; + + $httpPort = $input['httpPort'] ?? ''; + if (!$this->state->isValidPort($httpPort)) { + $this->respondBadRequest('Please enter a valid HTTP port (1-65535)', $wantsStream); + } + + $httpsPort = $input['httpsPort'] ?? ''; + if (!$this->state->isValidPort($httpsPort)) { + $this->respondBadRequest('Please enter a valid HTTPS port (1-65535)', $wantsStream); + } + + $emailCertificates = trim((string) ($input['emailCertificates'] ?? '')); + if ($emailCertificates === '' || !$this->state->isValidEmailAddress($emailCertificates)) { + $this->respondBadRequest('Please enter a valid email address', $wantsStream); + } + $input['emailCertificates'] = $emailCertificates; + + $opensslKey = trim((string) ($input['opensslKey'] ?? '')); + if (!$this->state->isValidSecretKey($opensslKey)) { + $this->respondBadRequest('Secret API key must be 1-64 characters', $wantsStream); + } + $input['opensslKey'] = $opensslKey; + + $assistantOpenAIKey = trim((string) ($input['assistantOpenAIKey'] ?? '')); + $input['assistantOpenAIKey'] = $assistantOpenAIKey; + + $account = []; + if (!$this->config->isUpgrade()) { + $accountEmail = trim((string) ($input['accountEmail'] ?? '')); + if ($accountEmail === '' || !$this->state->isValidEmailAddress($accountEmail)) { + $this->respondBadRequest('Please enter a valid email address', $wantsStream, Server::STEP_ACCOUNT_SETUP); + } + + $accountPassword = (string) ($input['accountPassword'] ?? ''); + if (!$this->state->isValidPassword($accountPassword)) { + $this->respondBadRequest('Password must be at least 8 characters', $wantsStream, Server::STEP_ACCOUNT_SETUP); + } + + // Derive name from email + $accountName = $this->deriveNameFromEmail($accountEmail); + + $input['accountEmail'] = $accountEmail; + $input['accountPassword'] = $accountPassword; + + $account = [ + 'name' => $accountName, + 'email' => $accountEmail, + 'password' => $accountPassword, + ]; + } + + $lockedDatabase = $this->config->getLockedDatabase(); + if (!$lockedDatabase) { + $database = strtolower(trim((string) ($input['database'] ?? ''))); + if (!$this->state->isValidDatabaseAdapter($database)) { + $this->respondBadRequest('Please select a supported database', $wantsStream); + } + $input['database'] = $database; + } + + $installId = $this->state->sanitizeInstallId($input['installId'] ?? ''); + if ($installId === '') { + $installId = bin2hex(random_bytes(8)); + } + + @unlink(Server::INSTALLER_COMPLETE_FILE); + + try { + $lockResult = $this->state->reserveGlobalLock($installId); + } catch (\Throwable $e) { + if ($wantsStream) { + $this->sendSseEvent(Server::STATUS_ERROR, ['message' => 'Lock failed: ' . $e->getMessage()]); + } else { + http_response_code(self::HTTP_INTERNAL_SERVER_ERROR); + echo json_encode(['success' => false, 'message' => 'Lock failed: ' . $e->getMessage()]); + } + return true; + } + + if ($lockResult !== 'ok') { + if ($lockResult === 'locked') { + http_response_code(self::HTTP_CONFLICT); + echo json_encode(['success' => false, 'message' => 'Installation already in progress']); + } else { + http_response_code(self::HTTP_SERVICE_UNAVAILABLE); + echo json_encode(['success' => false, 'message' => 'Installer lock unavailable']); + } + return true; + } + + $retryStep = $input['retryStep'] ?? null; + $allowedRetrySteps = [Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS]; + if (!is_string($retryStep) || !in_array($retryStep, $allowedRetrySteps, true)) { + $retryStep = null; + } + + $existingPath = $this->state->progressFilePath($installId); + $existing = null; + if (file_exists($existingPath)) { + $existing = $this->state->readProgressFile($installId); + if (!empty($existing['steps']) && $retryStep === null) { + http_response_code(self::HTTP_CONFLICT); + echo json_encode(['success' => false, 'message' => 'Installation already started']); + return true; + } + } + + try { + ignore_user_abort(true); + $this->state->ensureBootstrapped(); + require_once $this->paths['installPhp']; + $installer = new \Appwrite\Platform\Tasks\Install(); + + if ($wantsStream) { + $this->sendSseEvent('install-id', ['installId' => $installId]); + } + + $this->state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + + $payloadInput = [ + '_APP_ENV' => 'production', + '_APP_OPENSSL_KEY_V1' => $input['opensslKey'] ?? '', + '_APP_DOMAIN' => $input['appDomain'] ?? 'localhost', + '_APP_DOMAIN_TARGET' => $input['appDomain'] ?? 'localhost', + '_APP_EMAIL_CERTIFICATES' => $input['emailCertificates'] ?? '', + '_APP_DB_ADAPTER' => $lockedDatabase ?? ($input['database'] ?? 'mongodb'), + '_APP_ASSISTANT_OPENAI_API_KEY' => $input['assistantOpenAIKey'] ?? '', + ]; + + if ($this->hasPayload($existing)) { + $stored = $existing['payload']; + $fieldsToCompare = [ + 'httpPort', + 'httpsPort', + 'database', + 'appDomain', + 'emailCertificates', + ]; + foreach ($fieldsToCompare as $field) { + if (isset($stored[$field]) && isset($input[$field])) { + $storedValue = (string) $stored[$field]; + $inputValue = (string) $input[$field]; + if (in_array($field, ['httpPort', 'httpsPort'], true)) { + $storedValue = trim($storedValue); + $inputValue = trim($inputValue); + } + if ($storedValue !== $inputValue) { + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->respondBadRequest('Installation payload mismatch', $wantsStream); + } + } + } + + $sensitiveFields = [ + 'opensslKey' => 'opensslKeyHash', + 'assistantOpenAIKey' => 'assistantOpenAIKeyHash', + ]; + foreach ($sensitiveFields as $field => $hashField) { + if (!isset($stored[$hashField]) && !isset($stored[$field])) { + continue; + } + $incomingHash = $this->state->hashSensitiveValue((string) ($input[$field] ?? '')); + if (isset($stored[$hashField])) { + if (!hash_equals((string) $stored[$hashField], $incomingHash)) { + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->respondBadRequest('Installation payload mismatch', $wantsStream); + } + } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) { + if ($installId !== '') { + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->respondBadRequest('Installation payload mismatch', $wantsStream); + } + } + + $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']); + $input['httpPort'] = $stored['httpPort'] ?? $input['httpPort'] ?? $this->config->getDefaultHttpPort(); + $input['httpsPort'] = $stored['httpsPort'] ?? $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(); + } + + $vars = $this->config->getVars(); + $shouldGenerateSecrets = !$installer->hasExistingConfig() && !$this->config->isUpgrade(); + $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars, $shouldGenerateSecrets); + + $this->state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => $input['httpPort'] ?? $this->config->getDefaultHttpPort(), + 'httpsPort' => $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(), + 'database' => $lockedDatabase ?? ($input['database'] ?? 'mongodb'), + 'appDomain' => $input['appDomain'] ?? 'localhost', + 'emailCertificates' => $input['emailCertificates'] ?? '', + 'opensslKeyHash' => $this->state->hashSensitiveValue($input['opensslKey'] ?? ''), + 'assistantOpenAIKeyHash' => $this->state->hashSensitiveValue($input['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) { + $payload = [ + 'installId' => $installId, + 'step' => $step, + 'status' => $status, + 'message' => $message, + 'updatedAt' => time(), + ]; + if (!empty($details)) { + $payload['details'] = $details; + } + $this->state->writeProgressFile($installId, $payload); + $this->state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + if ($wantsStream) { + $this->sendSseEvent('progress', $payload); + } + }; + + $installer->performInstallation( + $input['httpPort'] ?? $this->config->getDefaultHttpPort(), + $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(), + $this->config->getOrganization(), + $this->config->getImage(), + $envVars, + $this->config->getNoStart(), + $progress, + $retryStep, + $this->config->isUpgrade(), + $account + ); + + if ($wantsStream) { + $this->sendSseEvent('done', ['installId' => $installId, 'success' => true]); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + echo ": keepalive\n\n"; + @ob_flush(); + @flush(); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + } else { + echo json_encode([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully', + ]); + } + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } catch (\Throwable $e) { + $this->handleInstallationError($e, $installId, $wantsStream); + } + + return true; + } + + private function handleInstallerView(string $uri): bool + { + if ($uri !== '/' && $uri !== '') { + return false; + } + + $csrfToken = $this->makeCsrf(); + $this->sendInstallerHtmlHeaders(); + + $vars = $this->config->getVars(); + $defaultHttpPort = $this->config->getDefaultHttpPort(); + $defaultHttpsPort = $this->config->getDefaultHttpsPort(); + $isUpgrade = $this->config->isUpgrade(); + $lockedDatabase = $this->config->getLockedDatabase(); + $isLocalInstall = $this->config->isLocal(); + + $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? ''; + if ($isLocalInstall && empty($defaultEmailCertificates)) { + $defaultEmailCertificates = 'walterobrien@example.com'; + } + + include $this->paths['views'] . '/installer.phtml'; + return true; + } + + private function sendInstallerHtmlHeaders(): void + { + header('Content-Type: text/html; charset=utf-8'); + header('Content-Security-Policy: ' . implode('; ', self::INSTALLER_CSP)); + } + + private function makeCsrf(): string + { + $existing = $_COOKIE[self::CSRF_COOKIE] ?? ''; + if (is_string($existing) && $existing !== '') { + return $existing; + } + + $token = bin2hex(random_bytes(16)); + setcookie(self::CSRF_COOKIE, $token, [ + 'path' => '/', + 'samesite' => 'Strict', + 'httponly' => true, + ]); + return $token; + } + + private function validateCsrf(bool $wantsStream): void + { + $cookie = $_COOKIE[self::CSRF_COOKIE] ?? ''; + $header = $_SERVER['HTTP_X_APPWRITE_INSTALLER_CSRF'] ?? ''; + + if (!is_string($cookie) || !is_string($header) || $cookie === '' || $header === '' || !hash_equals($cookie, $header)) { + $this->respondBadRequest('Invalid CSRF token', $wantsStream); + } + } + + private function sendSseEvent(string $event, array $payload): void + { + echo "event: $event\n"; + echo 'data: ' . json_encode($payload) . "\n\n"; + @ob_flush(); + @flush(); + } + + private function respondBadRequest(string $message, bool $wantsStream, string $step = Server::STEP_CONFIG_FILES): void + { + if ($wantsStream) { + $this->sendSseEvent(Server::STATUS_ERROR, ['message' => $message, 'step' => $step]); + } else { + http_response_code(self::HTTP_BAD_REQUEST); + echo json_encode(['success' => false, 'message' => $message]); + } + exit; + } + + private function buildErrorDetails(\Throwable $e): array + { + $details = ['trace' => $e->getTraceAsString()]; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + return $details; + } + + private function hasPayload(mixed $data): bool + { + return is_array($data) && isset($data['payload']) && is_array($data['payload']); + } + + private function handleInstallationError(\Throwable $e, string $installId, bool $wantsStream): void + { + http_response_code(self::HTTP_INTERNAL_SERVER_ERROR); + + if ($installId !== '') { + $this->state->writeProgressFile($installId, [ + 'step' => Server::STATUS_ERROR, + 'status' => Server::STATUS_ERROR, + 'message' => $e->getMessage(), + 'details' => $this->buildErrorDetails($e), + 'updatedAt' => time(), + ]); + $this->state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + + if ($wantsStream) { + $this->sendSseEvent(Server::STATUS_ERROR, [ + 'message' => $e->getMessage(), + 'details' => $this->buildErrorDetails($e) + ]); + } else { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + } + + /** + * Derives a name from an email address + * Examples: + * admin@example.com -> Admin + * admin.123@example.com -> Admin123 + */ + private function deriveNameFromEmail(string $email): string + { + // extract before @ symbol + $parts = explode('@', $email); + $username = $parts[0] ?? ''; + + // remove all non-alphanumeric characters + $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); + + // capitalize + return ucfirst($cleaned); + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php new file mode 100644 index 0000000000..370d9479da --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -0,0 +1,205 @@ +containsKnownKeys($values)) { + $this->setVars($values); + return; + } + $this->apply($values); + } + + public function apply(array $values): void + { + if ($this->hasValidStringValue($values, 'defaultHttpPort')) { + $this->setDefaultHttpPort((string) $values['defaultHttpPort']); + } + if ($this->hasValidStringValue($values, 'defaultHttpsPort')) { + $this->setDefaultHttpsPort((string) $values['defaultHttpsPort']); + } + if ($this->hasValidStringValue($values, 'organization')) { + $this->setOrganization((string) $values['organization']); + } + if ($this->hasValidStringValue($values, 'image')) { + $this->setImage((string) $values['image']); + } + if (array_key_exists('noStart', $values) && $values['noStart'] !== null) { + $this->setNoStart((bool) $values['noStart']); + } + if (array_key_exists('isUpgrade', $values) && $values['isUpgrade'] !== null) { + $this->setIsUpgrade((bool) $values['isUpgrade']); + } + if (array_key_exists('isLocal', $values) && $values['isLocal'] !== null) { + $this->setIsLocal((bool) $values['isLocal']); + } + if (array_key_exists('hostPath', $values)) { + $hostPath = $values['hostPath']; + $this->setHostPath($hostPath !== null && $hostPath !== '' ? (string) $hostPath : null); + } + if ($this->hasValidStringValue($values, 'lockedDatabase')) { + $this->setLockedDatabase((string) $values['lockedDatabase']); + } + if (array_key_exists('vars', $values) && is_array($values['vars'])) { + $this->setVars($values['vars']); + } + } + + private function hasValidStringValue(array $values, string $key): bool + { + return array_key_exists($key, $values) && $values[$key] !== null && $values[$key] !== ''; + } + + private function containsKnownKeys(array $values): bool + { + foreach (self::KNOWN_KEYS as $key) { + if (array_key_exists($key, $values)) { + return true; + } + } + return false; + } + + public function toArray(): array + { + return [ + 'defaultHttpPort' => $this->defaultHttpPort, + 'defaultHttpsPort' => $this->defaultHttpsPort, + 'organization' => $this->organization, + 'image' => $this->image, + 'noStart' => $this->noStart, + 'vars' => $this->vars, + 'isUpgrade' => $this->isUpgrade, + 'isLocal' => $this->isLocal, + 'hostPath' => $this->hostPath, + 'lockedDatabase' => $this->lockedDatabase, + ]; + } + + public function getDefaultHttpPort(): string + { + return $this->defaultHttpPort; + } + + public function setDefaultHttpPort(string $value): void + { + $this->defaultHttpPort = $value; + } + + public function getDefaultHttpsPort(): string + { + return $this->defaultHttpsPort; + } + + public function setDefaultHttpsPort(string $value): void + { + $this->defaultHttpsPort = $value; + } + + public function getOrganization(): string + { + return $this->organization; + } + + public function setOrganization(string $value): void + { + $this->organization = $value; + } + + public function getImage(): string + { + return $this->image; + } + + public function setImage(string $value): void + { + $this->image = $value; + } + + public function getNoStart(): bool + { + return $this->noStart; + } + + public function setNoStart(bool $value): void + { + $this->noStart = $value; + } + + public function getVars(): array + { + return $this->vars; + } + + public function setVars(array $vars): void + { + $this->vars = $vars; + } + + public function isUpgrade(): bool + { + return $this->isUpgrade; + } + + public function setIsUpgrade(bool $value): void + { + $this->isUpgrade = $value; + } + + public function isLocal(): bool + { + return $this->isLocal; + } + + public function setIsLocal(bool $value): void + { + $this->isLocal = $value; + } + + public function getHostPath(): ?string + { + return $this->hostPath; + } + + public function setHostPath(?string $value): void + { + $this->hostPath = $value; + } + + public function getLockedDatabase(): ?string + { + return $this->lockedDatabase; + } + + public function setLockedDatabase(?string $value): void + { + $this->lockedDatabase = $value; + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php new file mode 100644 index 0000000000..ab20297a80 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -0,0 +1,421 @@ +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($this->paths['init'], 2) . '/.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 $this->paths['vendor']; + require_once $this->paths['init']; + $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'], 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)); + } + + private function parseEnvFile(string $contents): array + { + $vars = []; + foreach ((array) preg_split(self::PATTERN_LINE_BREAKS, $contents) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + $pos = strpos($line, '='); + if ($pos === false) { + continue; + } + $key = trim(substr($line, 0, $pos)); + $value = trim(substr($line, $pos + 1)); + if ($key === '') { + continue; + } + $value = $this->stripEnvQuotes($value); + + $vars[] = [ + 'name' => $key, + 'default' => $value, + ]; + } + + return $vars; + } + + private function stripEnvQuotes(string $value): string + { + if ($value === '') { + return $value; + } + $first = $value[0]; + $last = substr($value, -1); + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + $value = substr($value, 1, -1); + } + return $value; + } + + private function globalLockPath(): string + { + return Server::INSTALLER_LOCK_FILE; + } + + private function isGlobalLockActive(?array $lock): bool + { + if (!$lock || !isset($lock['updatedAt'])) { + return false; + } + + if (isset($lock['status']) && in_array($lock['status'], [Server::STATUS_COMPLETED, Server::STATUS_ERROR], true)) { + return false; + } + + if (time() - (int) $lock['updatedAt'] > self::GLOBAL_LOCK_TIMEOUT_SECONDS) { + return false; + } + + return true; + } + + private function withGlobalLock(callable $callback) + { + $path = $this->globalLockPath(); + $handle = fopen($path, 'c+'); + if ($handle === false) { + return $callback(null, null); + } + if (!flock($handle, LOCK_EX)) { + fclose($handle); + return $callback(null, null); + } + + $contents = stream_get_contents($handle); + $lock = null; + if ($contents !== false && $contents !== '') { + $decoded = json_decode($contents, true); + if (is_array($decoded)) { + $lock = $decoded; + } + } + + try { + $result = $callback($handle, $lock); + } finally { + fflush($handle); + flock($handle, LOCK_UN); + fclose($handle); + } + + return $result; + } + + private function isValidAppDomain(string $value): bool + { + if ($value === 'localhost') { + return true; + } + if (filter_var($value, FILTER_VALIDATE_IP) !== false) { + return true; + } + return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } +} diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php new file mode 100644 index 0000000000..d0b4b51ee1 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Server.php @@ -0,0 +1,326 @@ +initPaths(); + + $this->state = new State($this->paths); + + /* launches the install/upgrade entrypoint in Docker. */ + if (PHP_SAPI === 'cli') { + $this->runCli(); + return; + } + + $handler = new HttpHandler($this->paths, $this->state); + + $handler->handleRequest(); + } + + private function initPaths(): void + { + if (!empty($this->paths)) { + return; + } + + $root = dirname(__DIR__, 4); + $this->paths = [ + 'public' => $root . '/public', + 'init' => $root . '/app/init.php', + 'views' => $root . '/app/views/install', + 'vendor' => $root . '/vendor/autoload.php', + 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', + ]; + } + + private function runCli(): void + { + $opts = getopt('', ['upgrade', 'locked-database::', 'docker', 'clean']); + $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 = (string) self::INSTALLER_WEB_PORT; + + if (isset($opts['clean'])) { + $this->removeDockerInstallerContainer(self::DEFAULT_CONTAINER); + $this->killInstallerDevServers(); + $this->cleanupWebInstallerFiles(); + exit(0); + } + + if (isset($opts['docker'])) { + $this->printInstallerUrl($host, $port); + $this->startDockerInstaller($opts); + } + + $this->printInstallerUrl($host, $port); + $this->startInstallerDevServer($host, $port); + } + + 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 killInstallerDevServers(): void + { + $pattern = 'php -S .*app/views/install'; + $command = 'pkill -f ' . escapeshellarg($pattern) . ' >/dev/null 2>&1'; + exec($command); + } + + private function startInstallerDevServer(string $host, string $port): void + { + $binary = escapeshellcmd(PHP_BINARY); + $address = escapeshellarg("$host:$port"); + $docroot = escapeshellarg($this->paths['views']); + $router = escapeshellarg(__FILE__); + $command = "$binary -S $address -t $docroot $router"; + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + if (!is_resource($process)) { + fwrite(STDERR, "Failed to start PHP dev server." . PHP_EOL); + exit(1); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $lastStatus = null; + while (true) { + $read = [$pipes[1], $pipes[2]]; + $write = null; + $except = null; + + if (stream_select($read, $write, $except, null) === false) { + break; + } + + foreach ($read as $stream) { + $line = fgets($stream); + if ($line === false) { + continue; + } + + if ($stream === $pipes[2] && preg_match(self::DEV_SERVER_START_PATTERN, $line)) { + continue; + } + if (preg_match(self::PATTERN_SERVER_LOG_FILTER, $line)) { + continue; + } + + $target = ($stream === $pipes[2]) ? STDERR : STDOUT; + fwrite($target, $line); + } + + $status = proc_get_status($process); + $lastStatus = $status; + if (!$status['running']) { + break; + } + } + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + $exitCode = proc_close($process); + /** + * Treat exit code 255 or signaled status as a clean shutdown. + */ + if ($exitCode === 255 || ($lastStatus['signaled'] ?? false)) { + $exitCode = 0; + } + exit($exitCode); + } + + 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 execution to avoid Swoole exit exception during autoload. + * + * @return bool + */ +function shouldRunInstallerServer(): bool +{ + return PHP_SAPI === 'cli-server' || (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__); +} + +if (shouldRunInstallerServer()) { + $server = new Server(); + $server->run(); +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index cb8b0dd42e..5b7a01888c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -103,11 +103,9 @@ class Get extends Action // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries); - } elseif (! empty($selects)) { - // has selects, allow relationship on documents! + } elseif (!empty($selects)) { $document = $dbForProject->getDocument($collectionTableId, $documentId, $queries); } else { - // has no selects, disable relationship looping on documents! $document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries)); } } catch (QueryException $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index ff94e67b02..484c034d6e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -16,6 +16,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; @@ -151,6 +152,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } catch (Timeout) { + throw new Exception(Exception::DATABASE_TIMEOUT); } $operations = 0; diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index e51ea3188a..c354bf9cb7 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -4,17 +4,36 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Docker\Compose; use Appwrite\Docker\Env; +use Appwrite\Platform\Installer\Server as InstallerServer; use Appwrite\Utopia\View; use Utopia\Auth\Proofs\Password; use Utopia\Auth\Proofs\Token; use Utopia\CLI\Console; use Utopia\Config\Config; +use Utopia\Fetch\Client; use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; class Install extends Action { + private const int INSTALL_STEP_DELAY_SECONDS = 2; + private const int WEB_SERVER_CHECK_ATTEMPTS = 10; + private const int WEB_SERVER_CHECK_DELAY_SECONDS = 1; + + private const int HEALTH_CHECK_ATTEMPTS = 10; + private const int HEALTH_CHECK_DELAY_SECONDS = 3; + + private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/'; + private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/'; + private const string PATTERN_SESSION_COOKIE = '/a_session_console=([^;]+)/'; + + private const string APPWRITE_API_URL = 'http://appwrite'; + private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; + + protected string $hostPath = ''; + protected ?bool $isLocalInstall = null; + protected ?array $installerConfig = null; protected string $path = '/usr/src/code/appwrite'; public static function getName(): string @@ -35,12 +54,12 @@ class Install extends Action ->callback($this->action(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void + public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void { + $defaultHttpPort = '80'; + $defaultHttpsPort = '443'; $config = Config::getParam('variables'); - $defaultHTTPPort = '80'; - $defaultHTTPSPort = '443'; - /** @var array> $vars array whre key is variable name and value is variable */ + /** @var array> $vars array where key is variable name and value is variable */ $vars = []; foreach ($config as $category) { @@ -51,6 +70,9 @@ class Install extends Action Console::success('Starting Appwrite installation...'); + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Create directory with write permissions if (!\file_exists(\dirname($this->path))) { if (!@\mkdir(\dirname($this->path), 0755, true)) { @@ -59,21 +81,16 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/docker-compose.yml'); - - if ($data !== false) { - if ($interactive == 'Y' && Console::isInteractive()) { - $answer = Console::confirm('Previous installation found, do you want to overwrite it (a backup will be created before overwriting)? (Y/n)'); - - if (\strtolower($answer) !== 'y') { - Console::info('No action taken.'); - return; - } - } + // Check for existing installation + $data = $this->readExistingCompose(); + $envFileExists = file_exists($this->path . '/' . $this->getEnvFileName()); + $existingInstallation = $data !== '' || $envFileExists; + if ($existingInstallation) { $time = \time(); - Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup'); - file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data); + $composeFileName = $this->getComposeFileName(); + Console::info('Compose file found, creating backup: ' . $composeFileName . '.' . $time . '.backup'); + file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); $oldVersion = $appwrite?->getImageVersion(); @@ -81,14 +98,14 @@ class Install extends Action $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { $ports = [ - $defaultHTTPPort => $defaultHTTPPort, - $defaultHTTPSPort => $defaultHTTPSPort + $defaultHttpPort => $defaultHttpPort, + $defaultHttpsPort => $defaultHttpsPort ]; Console::warning('Traefik not found. Falling back to default ports.'); } if ($oldVersion) { - foreach ($compose->getServices() as $service) { // Fetch all env vars from previous compose file + foreach ($compose->getServices() as $service) { if (!$service) { continue; } @@ -107,12 +124,14 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName()); - if ($data !== false) { // Fetch all env vars from previous .env file - Console::info('Env file found, creating backup: .env.' . $time . '.backup'); - file_put_contents($this->path . '/.env.' . $time . '.backup', $data); - $env = new Env($data); + if ($envData !== false) { + if (!$isLocalInstall) { + Console::info('Env file found, creating backup: .env.' . $time . '.backup'); + file_put_contents($this->path . '/.env.' . $time . '.backup', $envData); + } + $env = new Env($envData); foreach ($env->list() as $key => $value) { if (is_null($value)) { @@ -127,40 +146,40 @@ class Install extends Action } foreach ($ports as $key => $value) { - if ($value === $defaultHTTPPort) { - $defaultHTTPPort = $key; + if ($value === $defaultHttpPort) { + $defaultHttpPort = $key; } - if ($value === $defaultHTTPSPort) { - $defaultHTTPSPort = $key; + if ($value === $defaultHttpsPort) { + $defaultHttpsPort = $key; } } } } - if (empty($httpPort)) { - $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); - $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; - } + // If interactive and web mode enabled, start web server + if ($interactive === 'Y' && Console::isInteractive()) { + Console::success('Starting web installer...'); + Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT); + Console::info('Press Ctrl+C to cancel installation'); - if (empty($httpsPort)) { - $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHTTPSPort . ')'); - $httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort; + $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars); + return; } + // Fall back to CLI mode $enableAssistant = false; $assistantExistsInOldCompose = false; - - if ($data !== false && isset($compose)) { + if ($existingInstallation && isset($compose)) { try { $assistantService = $compose->getService('appwrite-assistant'); $assistantExistsInOldCompose = $assistantService !== null; } catch (\Throwable) { - // assistant service doesn't exist, keep default false + /* ignore */ } } - if ($interactive == 'Y' && Console::isInteractive()) { + if ($interactive === 'Y' && Console::isInteractive()) { $prompt = 'Add Appwrite Assistant? (Y/n)' . ($assistantExistsInOldCompose ? ' [Currently enabled]' : ''); $answer = Console::confirm($prompt); @@ -173,125 +192,794 @@ class Install extends Action $enableAssistant = true; } - $input = []; + if (empty($httpPort)) { + $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')'); + $httpPort = ($httpPort) ?: $defaultHttpPort; + } - $password = new Password(); - $token = new Token(); + if (empty($httpsPort)) { + $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHttpsPort . ')'); + $httpsPort = ($httpsPort) ?: $defaultHttpsPort; + } + + $userInput = []; foreach ($vars as $var) { if ($var['name'] === '_APP_ASSISTANT_OPENAI_API_KEY') { if (!$enableAssistant) { - $input[$var['name']] = ''; + $userInput[$var['name']] = ''; continue; } - // key already exists if (!empty($var['default'])) { - $input[$var['name']] = $var['default']; + $userInput[$var['name']] = $var['default']; continue; } - // if assistant enabled and no key, ask for it if (Console::isInteractive() && $interactive === 'Y') { - $input[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:'); - if (empty($input[$var['name']])) { + $userInput[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:'); + if (empty($userInput[$var['name']])) { Console::warning('No API key provided. Assistant will be disabled.'); $enableAssistant = false; - $input[$var['name']] = ''; + $userInput[$var['name']] = ''; } - continue; + } else { + $userInput[$var['name']] = ''; } - $input[$var['name']] = ''; continue; } - if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) { - if ($data && $var['default'] !== null) { - $input[$var['name']] = $var['default']; - continue; - } - - if ($var['filter'] === 'token') { - $input[$var['name']] = $token->generate(); - continue; - } - - if ($var['filter'] === 'password') { - $input[$var['name']] = $password->generate(); - continue; - } - } if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') { - $input[$var['name']] = $var['default']; continue; } - $input[$var['name']] = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')'); + $value = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')'); - if (empty($input[$var['name']])) { - $input[$var['name']] = $var['default']; + if (!empty($value)) { + $userInput[$var['name']] = $value; } - if ($var['filter'] === 'domainTarget') { - if ($input[$var['name']] !== 'localhost') { - Console::warning("\nIf you haven't already done so, set the following record for {$input[$var['name']]} on your DNS provider:\n"); - $mask = "%-15.15s %-10.10s %-30.30s\n"; - printf($mask, "Type", "Name", "Value"); - printf($mask, "A or AAAA", "@", ""); - Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n"); - } + if ($var['filter'] === 'domainTarget' && !empty($value) && $value !== 'localhost') { + Console::warning("\nIf you haven't already done so, set the following record for {$value} on your DNS provider:\n"); + $mask = "%-15.15s %-10.10s %-30.30s\n"; + printf($mask, "Type", "Name", "Value"); + printf($mask, "A or AAAA", "@", ""); + Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n"); } } - $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml'); - $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml'); + $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade; + $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); + } + + + protected function startWebServer(string $defaultHttpPort, string $defaultHttpsPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void + { + $host = InstallerServer::INSTALLER_WEB_HOST; + $port = InstallerServer::INSTALLER_WEB_PORT; + + @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); + + $this->setInstallerConfig([ + 'defaultHttpPort' => $defaultHttpPort, + 'defaultHttpsPort' => $defaultHttpsPort, + 'organization' => $organization, + 'image' => $image, + 'noStart' => $noStart, + 'vars' => $vars, + 'isUpgrade' => $isUpgrade, + 'lockedDatabase' => $lockedDatabase, + 'isLocal' => $this->isLocalInstall(), + 'hostPath' => $this->hostPath ?: null, + ]); + $routerScript = dirname(__DIR__) . '/Installer/Server.php'; + $docroot = $this->buildFromProjectPath('/app/views/install'); + + // Start PHP built-in server in background + $command = \sprintf( + 'php -S %s:%d -t %s %s 2>&1 & echo $!', + $host, + $port, + \escapeshellarg($docroot), + \escapeshellarg($routerScript) + ); + $output = []; + \exec($command, $output); + $pid = isset($output[0]) ? (int) $output[0] : 0; + + \register_shutdown_function(function () use ($pid) { + if ($pid > 0 && \function_exists('posix_kill')) { + @\posix_kill($pid, SIGTERM); + } + }); + \sleep(3); + + if (!$this->waitForWebServer($port)) { + Console::warning('Web installer did not respond in time. Please refresh the browser.'); + return; + } + + if ($this->isInstallationComplete($port)) { + Console::success('Installation completed.'); + } + } + + public function prepareEnvironmentVariables(array $userInput, array $vars, bool $shouldGenerateSecrets = true): array + { + $input = []; + $password = new Password(); + $token = new Token(); + + // Start with all defaults + foreach ($vars as $var) { + $filter = $var['filter'] ?? null; + $default = $var['default'] ?? null; + $hasDefault = $default !== null && $default !== ''; + + if ($filter === 'token') { + if ($hasDefault) { + $input[$var['name']] = $default; + } elseif ($shouldGenerateSecrets) { + $input[$var['name']] = $token->generate(); + } else { + $input[$var['name']] = ''; + } + } elseif ($filter === 'password') { + if ($hasDefault) { + $input[$var['name']] = $default; + } elseif ($shouldGenerateSecrets) { + /*;#+@:/?& broke DSNs locally */ + $input[$var['name']] = $this->generatePasswordValue($var['name'], $password); + } else { + $input[$var['name']] = ''; + } + } else { + $input[$var['name']] = $default; + } + } + + // Override with user inputs + foreach ($userInput as $key => $value) { + if ($value !== null && ($value !== '' || $key === '_APP_ASSISTANT_OPENAI_API_KEY')) { + $input[$key] = $value; + } + } + + foreach ($input as $key => $value) { + if (!is_string($value)) { + continue; + } + if (str_contains($value, "\n") || str_contains($value, "\r")) { + throw new \InvalidArgumentException('Invalid value for ' . $key); + } + } + + // Set database-specific connection details + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + if ($database === 'mongodb') { + $input['_APP_DB_HOST'] = 'mongodb'; + $input['_APP_DB_PORT'] = 27017; + } elseif ($database === 'mariadb') { + $input['_APP_DB_HOST'] = 'mariadb'; + $input['_APP_DB_PORT'] = 3306; + } + + return $input; + } + + public function hasExistingConfig(): bool + { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + + if ($this->readExistingCompose() !== '') { + return true; + } + + return file_exists($this->path . '/' . $this->getEnvFileName()); + } + + private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void + { + if (!$progress) { + return; + } + + if ($messageOverride !== null) { + $message = $messageOverride; + } else { + $key = $status === InstallerServer::STATUS_COMPLETED ? 'done' : 'start'; + $message = $messages[$step][$key] ?? null; + if ($message === null) { + return; + } + } + + try { + $progress($step, $status, $message, $details); + } catch (\Throwable $e) { + } + if ($status === InstallerServer::STATUS_IN_PROGRESS) { + sleep(self::INSTALL_STEP_DELAY_SECONDS); + } + } + + private function setInstallerConfig(array $config): void + { + $json = json_encode($config, JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + return; + } + + putenv('APPWRITE_INSTALLER_CONFIG=' . $json); + $path = InstallerServer::INSTALLER_CONFIG_FILE; + if (@file_put_contents($path, $json) === false) { + return; + } + @chmod($path, 0600); + } + + public function performInstallation( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + array $input, + bool $noStart, + ?callable $progress = null, + ?string $resumeFromStep = null, + bool $isUpgrade = false, + array $account = [] + ): void { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, false); + + $isCLI = php_sapi_name() === 'cli'; + if ($isLocalInstall) { + $useExistingConfig = false; + } else { + $useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName()) + && file_exists($this->path . '/' . $this->getEnvFileName()); + } + + if ($isLocalInstall) { + $image = 'appwrite'; + $organization = 'appwrite'; + } + + $templateForEnv = new View($this->buildFromProjectPath('/app/views/install/env.phtml')); + $templateForCompose = new View($this->buildFromProjectPath('/app/views/install/compose.phtml')); + + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + + $version = \defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest'; + if ($isLocalInstall) { + $version = 'local'; + } + + $assistantKey = (string) ($input['_APP_ASSISTANT_OPENAI_API_KEY'] ?? ''); + $enableAssistant = trim($assistantKey) !== ''; $templateForCompose ->setParam('httpPort', $httpPort) ->setParam('httpsPort', $httpsPort) - ->setParam('version', APP_VERSION_STABLE) + ->setParam('version', $version) ->setParam('organization', $organization) ->setParam('image', $image) + ->setParam('database', $database) + ->setParam('hostPath', $this->hostPath) ->setParam('enableAssistant', $enableAssistant); $templateForEnv->setParam('vars', $input); - if (!file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { - $message = 'Failed to save Docker Compose file'; - Console::error($message); - Console::exit(1); - } + $steps = [ + InstallerServer::STEP_DOCKER_COMPOSE, + InstallerServer::STEP_ENV_VARS, + InstallerServer::STEP_DOCKER_CONTAINERS + ]; - if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - $message = 'Failed to save environment variables file'; - Console::error($message); - Console::exit(1); - } - - $env = ''; - $stdout = ''; - $stderr = ''; - - foreach ($input as $key => $value) { - if ($value) { - $env .= $key . '=' . \escapeshellarg($value) . ' '; + $startIndex = 0; + if ($resumeFromStep !== null) { + $resumeIndex = array_search($resumeFromStep, $steps, true); + if ($resumeIndex !== false) { + $startIndex = $resumeIndex; } } - $exit = 0; - if (!$noStart) { - Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); - $exit = Console::execute("$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes", '', $stdout, $stderr); - } + $currentStep = null; - if ($exit !== 0) { - $message = 'Failed to install Appwrite dockers'; - Console::error($message); - Console::error($stderr); - Console::exit($exit); - } else { - $message = 'Appwrite installed successfully'; - Console::success($message); + $messages = $this->buildStepMessages($isUpgrade); + + try { + if ($startIndex <= 1) { + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_IN_PROGRESS, $messages); + } + + if ($startIndex <= 0) { + $currentStep = InstallerServer::STEP_DOCKER_COMPOSE; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages); + + if (!$useExistingConfig) { + $this->writeComposeFile($templateForCompose); + } + + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages); + } + + if ($startIndex <= 1) { + $currentStep = InstallerServer::STEP_ENV_VARS; + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages); + + if (!$useExistingConfig) { + $this->writeEnvFile($templateForEnv); + } + + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_COMPLETED, $messages); + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages); + } + + if ($database === 'mongodb' && !$useExistingConfig) { + $this->copyMongoEntrypointIfNeeded(); + } + + if (!$noStart && $startIndex <= 2) { + $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI); + $this->connectInstallerToAppwriteNetwork(); + + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + + if (!$isUpgrade) { + // Create console account on fresh install only + $this->createInitialAdminAccount($account, $progress); + } + + /* track installs for metrics */ + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + + if ($isCLI) { + Console::success('Appwrite installed successfully'); + } + } else { + if ($isCLI) { + Console::success('Installation files created. Run "docker compose up -d" to start Appwrite'); + } + } + } catch (\Throwable $e) { + if ($currentStep) { + $details = [ + 'trace' => $e->getTraceAsString() + ]; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + $this->updateProgress($progress, $currentStep, InstallerServer::STATUS_ERROR, $messages, $details, $e->getMessage()); + } + throw $e; } } + + private function createInitialAdminAccount(array $account, ?callable $progress): void + { + $name = $account['name'] ?? 'Admin'; + $email = $account['email'] ?? null; + $password = $account['password'] ?? null; + + if (!$email || !$password) { + return; + } + + try { + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_IN_PROGRESS, + messageOverride: 'Creating Appwrite account' + ); + + $this->waitForApiReady(); + + // Create account and session + $userId = $this->makeApiCall('/v1/account', [ + 'userId' => 'unique()', + 'email' => $email, + 'password' => $password, + 'name' => $name + ]); + + $session = $this->makeApiCall('/v1/account/sessions/email', [ + 'email' => $email, + 'password' => $password + ], true); + + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_COMPLETED, + details: [ + 'userId' => $userId, + 'sessionId' => $session['id'], + 'sessionSecret' => $session['secret'], + 'sessionExpire' => $session['expire'] ?? null + ], + messageOverride: 'Account created successfully' + ); + } catch (\Throwable $e) { + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_ERROR, + messageOverride: 'Account creation failed: ' . $e->getMessage() + ); + } + } + + private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void + { + if ($this->isLocalInstall()) { + return; + } + + $appEnv = $input['_APP_ENV'] ?? 'development'; + $domain = $input['_APP_DOMAIN'] ?? 'localhost'; + + /* local or test instance */ + if ($appEnv !== 'production') { + return; + } + + /* prod but local or test instance */ + if ($domain === 'localhost' + || str_starts_with($domain, '127.') + || str_starts_with($domain, '0.0.0.0') + ) { + return; + } + + $type = $isUpgrade ? 'upgrade' : 'install'; + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + $name = $account['name'] ?? 'Admin'; + $email = $account['email'] ?? 'admin@selfhosted.local'; + + $payload = [ + 'action' => $type, + 'account' => 'self-hosted', + 'url' => 'https://' . $domain, + 'category' => 'self_hosted', + 'label' => 'self_hosted_' . $type, + 'version' => $version, + 'data' => json_encode([ + 'name' => $name, + 'email' => $email, + 'domain' => $domain, + 'database' => $database, + ]), + ]; + + try { + $client = new Client(); + $client + ->addHeader('Content-Type', 'application/json') + ->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload); + } catch (\Throwable) { + // tracking shouldn't block installation + } + } + + private function waitForApiReady(): void + { + $client = new Client(); + $pingHealth = function () use ($client): bool { + try { + return $client->fetch(self::APPWRITE_API_URL . '/v1/health/version')->getStatusCode() === 200; + } catch (\Throwable) { + return false; + } + }; + + for ($i = 0; $i < self::HEALTH_CHECK_ATTEMPTS; $i++) { + if ($pingHealth()) { + return; + } + + if ($i < self::HEALTH_CHECK_ATTEMPTS - 1) { + sleep(self::HEALTH_CHECK_DELAY_SECONDS); + } + } + + throw new \Exception('Failed to connect with Appwrite in time'); + } + + /* easier to just connect and call appwrite endpoint */ + private function connectInstallerToAppwriteNetwork(): void + { + $network = escapeshellarg('appwrite'); + $container = escapeshellarg(InstallerServer::DEFAULT_CONTAINER); + @exec("docker network connect $network $container 2>/dev/null"); + } + + private function makeApiCall(string $endpoint, array $body, bool $extractSession = false) + { + $client = new Client(); + $client + ->setTimeout(30) + ->addHeader('Content-Type', 'application/json') + ->addHeader('X-Appwrite-Project', 'console'); + + $url = self::APPWRITE_API_URL . $endpoint; + $response = $client->fetch($url, Client::METHOD_POST, $body); + + if ($response->getStatusCode() !== 201) { + $error = $response->json(); + $message = $error['message'] ?? 'Unknown error'; + throw new \Exception($message); + } + + $data = $response->json(); + if (!isset($data['$id'])) { + throw new \Exception('API response missing ID field'); + } + + if ($extractSession) { + $headers = $response->getHeaders(); + $setCookie = $headers['set-cookie'] ?? $headers['Set-Cookie'] ?? null; + + if (!$setCookie || !preg_match(self::PATTERN_SESSION_COOKIE, $setCookie, $matches)) { + throw new \Exception('Session created but no cookie found'); + } + + return [ + 'id' => $data['$id'], + 'secret' => $matches[1], + 'expire' => $data['expire'] ?? null + ]; + } + + return $data['$id']; + } + + private function buildStepMessages(bool $isUpgrade): array + { + $isUpgradeLabel = $isUpgrade ? 'updated' : 'created'; + $verbs = [ + InstallerServer::STEP_CONFIG_FILES => $isUpgrade ? 'Updating' : 'Creating', + InstallerServer::STEP_DOCKER_COMPOSE => $isUpgrade ? 'Updating' : 'Generating', + InstallerServer::STEP_ENV_VARS => $isUpgrade ? 'Updating' : 'Configuring', + InstallerServer::STEP_DOCKER_CONTAINERS => $isUpgrade ? 'Restarting' : 'Starting', + ]; + + return [ + InstallerServer::STEP_CONFIG_FILES => [ + 'start' => $verbs[InstallerServer::STEP_CONFIG_FILES] . ' configuration files...', + 'done' => 'Configuration files ' . $isUpgradeLabel, + ], + InstallerServer::STEP_DOCKER_COMPOSE => [ + 'start' => $verbs[InstallerServer::STEP_DOCKER_COMPOSE] . ' Docker Compose file...', + 'done' => 'Docker Compose file ' . $isUpgradeLabel, + ], + InstallerServer::STEP_ENV_VARS => [ + 'start' => $verbs[InstallerServer::STEP_ENV_VARS] . ' environment variables...', + 'done' => 'Environment variables ' . $isUpgradeLabel, + ], + InstallerServer::STEP_DOCKER_CONTAINERS => [ + 'start' => $verbs[InstallerServer::STEP_DOCKER_CONTAINERS] . ' Docker containers...', + 'done' => $isUpgrade ? 'Docker containers restarted' : 'Docker containers started', + ], + ]; + } + + private function writeComposeFile(View $template): void + { + $composeFileName = $this->getComposeFileName(); + $targetPath = $this->path . '/' . $composeFileName; + $renderedContent = $template->render(false); + + $result = @file_put_contents($targetPath, $renderedContent); + if ($result === false) { + $lastError = error_get_last(); + $errorMsg = $lastError ? $lastError['message'] : 'Unknown error'; + throw new \Exception('Failed to save Docker Compose file: ' . $errorMsg . ' (path: ' . $targetPath . ')'); + } + } + + private function writeEnvFile(View $template): void + { + $envFileName = $this->getEnvFileName(); + if (!\file_put_contents($this->path . '/' . $envFileName, $template->render(false))) { + throw new \Exception('Failed to save environment variables file'); + } + } + + private function copyMongoEntrypointIfNeeded(): void + { + $mongoEntrypoint = $this->buildFromProjectPath('/mongo-entrypoint.sh'); + + if (file_exists($mongoEntrypoint)) { + // Always use container path for file operations + copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); + } + } + + protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void + { + $env = ''; + if (!$useExistingConfig) { + foreach ($input as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (!preg_match(self::PATTERN_ENV_VAR_NAME, $key)) { + throw new \Exception("Invalid environment variable name: $key"); + } + $env .= $key . '=' . \escapeshellarg((string) $value) . ' '; + } + } + + if ($isCLI) { + Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); + } + + $composeFileName = $this->getComposeFileName(); + $composeFile = $this->path . '/' . $composeFileName; + + $command = [ + 'docker', + 'compose', + '-f', + $composeFile, + ]; + + if ($isLocalInstall) { + $command[] = '--project-name'; + $command[] = 'appwrite'; + } + + $command[] = '--project-directory'; + $command[] = $this->path; + $command[] = 'up'; + $command[] = '-d'; + $command[] = '--remove-orphans'; + $command[] = '--renew-anon-volumes'; + $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1'; + \exec($commandLine, $output, $exit); + + if ($exit !== 0) { + $message = trim(implode("\n", $output)); + $previous = $message !== '' ? new \RuntimeException($message) : null; + throw new \RuntimeException('Failed to start containers', 0, $previous); + } + if ($isLocalInstall && $isCLI && !empty($output)) { + Console::log(implode("\n", $output)); + } + } + + protected function isLocalInstall(): bool + { + if ($this->isLocalInstall === null) { + $config = $this->readInstallerConfig(); + $this->isLocalInstall = !empty($config['isLocal']); + } + + return $this->isLocalInstall; + } + + protected function readInstallerConfig(): array + { + if ($this->installerConfig !== null) { + return $this->installerConfig; + } + + $this->installerConfig = []; + $decodeConfig = static function (string $json): ?array { + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : null; + }; + + $json = getenv('APPWRITE_INSTALLER_CONFIG'); + $path = InstallerServer::INSTALLER_CONFIG_FILE; + $fileJson = file_exists($path) ? file_get_contents($path) : null; + + foreach ([$json, $fileJson] as $candidate) { + if (!is_string($candidate) || $candidate === '') { + continue; + } + + $decoded = $decodeConfig($candidate); + if ($decoded !== null) { + $this->installerConfig = $decoded; + return $this->installerConfig; + } + } + + return $this->installerConfig; + } + + protected function getInstallerHostPath(): string + { + $config = $this->readInstallerConfig(); + if (!empty($config['hostPath'])) { + return (string) $config['hostPath']; + } + + $cwd = getcwd(); + return $cwd !== false ? $cwd : '.'; + } + + protected function buildFromProjectPath(string $suffix): string + { + if ($suffix !== '' && $suffix[0] !== '/') { + $suffix = '/' . $suffix; + } + return dirname(__DIR__, 4) . $suffix; + } + + protected function applyLocalPaths(bool $isLocalInstall, bool $force = false): void + { + if (!$isLocalInstall) { + return; + } + if (!$force && $this->hostPath !== '') { + return; + } + $this->path = '/usr/src/code'; + $this->hostPath = $this->getInstallerHostPath(); + } + + protected function readExistingCompose(): string + { + $composeFile = $this->path . '/' . $this->getComposeFileName(); + $data = @file_get_contents($composeFile); + return !empty($data) ? $data : ''; + } + + protected function generatePasswordValue(string $varName, Password $password): string + { + $value = $password->generate(); + if (!\preg_match(self::PATTERN_DB_PASSWORD_VAR, $varName)) { + return $value; + } + + return rtrim(strtr(base64_encode(hash('sha256', $value, true)), '+/', '-_'), '='); + } + + protected function getComposeFileName(): string + { + return $this->isLocalInstall() ? 'docker-compose.web-installer.yml' : 'docker-compose.yml'; + } + + protected function getEnvFileName(): string + { + return $this->isLocalInstall() ? '.env.web-installer' : '.env'; + } + + private function isInstallationComplete(int $port): bool + { + while (true) { + if (file_exists(InstallerServer::INSTALLER_COMPLETE_FILE)) { + return true; + } + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle === false) { + return false; + } + \fclose($handle); + \sleep(1); + } + } + + private function waitForWebServer(int $port): bool + { + for ($attempt = 0; $attempt < self::WEB_SERVER_CHECK_ATTEMPTS; $attempt++) { + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle !== false) { + \fclose($handle); + return true; + } + \sleep(self::WEB_SERVER_CHECK_DELAY_SECONDS); + } + return false; + } } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index d6159d2718..45f262bf96 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -2,12 +2,16 @@ namespace Appwrite\Platform\Tasks; +use Appwrite\Docker\Env; use Utopia\CLI\Console; +use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Text; class Upgrade extends Install { + private ?string $lockedDatabase = null; + public static function getName(): string { return 'upgrade'; @@ -15,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) @@ -23,22 +29,43 @@ class Upgrade extends Install ->param('image', 'appwrite', new Text(0), 'Main appwrite docker image', true) ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) - ->callback($this->action(...)); + ->callback($this->upgradeAction(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void + public function upgradeAction(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Check for previous installation - $data = @file_get_contents($this->path . '/docker-compose.yml'); - if (empty($data)) { + if (empty($this->readExistingCompose())) { 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; } - parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart); + + $database = null; + $envPath = $this->path . '/' . $this->getEnvFileName(); + $envData = @file_get_contents($envPath); + if ($envData !== false) { + $env = new Env($envData); + $envVars = $env->list(); + $database = $envVars['_APP_DB_ADAPTER'] ?? null; + } + if (empty($database)) { + $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb'); + } + $this->lockedDatabase = (string) $database; + + parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, true); + } + + 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); } }