Add web installer

This commit is contained in:
Jake Barnby
2025-12-02 23:33:21 +13:00
parent adc0d3dbd0
commit cbf0cc842d
17 changed files with 1693 additions and 101 deletions
+3 -1
View File
@@ -17,4 +17,6 @@ dev/yasd_init.php
.phpunit.result.cache
Makefile
appwrite.config.json
/.zed/
/.zed/
playwright-report
test-results
+2
View File
@@ -39,6 +39,7 @@ COPY ./bin /usr/local/bin
COPY ./docs /usr/src/code/docs
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 && \
@@ -101,5 +102,6 @@ RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi
RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xdebug.so; fi
EXPOSE 80
EXPOSE 8080
CMD [ "php", "app/http.php" ]
+45 -16
View File
@@ -11,6 +11,9 @@ $httpsPort = $this->getParam('httpsPort', '');
$version = $this->getParam('version', '');
$organization = $this->getParam('organization', '');
$image = $this->getParam('image', '');
$dbService = $this->getParam('database');
$hostPath = $this->getParam('hostPath', '');
?>services:
traefik:
image: traefik:2.11
@@ -69,7 +72,7 @@ $image = $this->getParam('image', '');
- appwrite-sites:/storage/sites:rw
- appwrite-builds:/storage/builds:rw
depends_on:
- mariadb
- <?= $dbService . "\n" ?>
- redis
# - clamav
environment:
@@ -221,7 +224,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- mariadb
- <?= $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -251,7 +254,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -277,7 +280,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -305,7 +308,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@@ -368,7 +371,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -394,7 +397,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
volumes:
- appwrite-functions:/storage/functions:rw
- appwrite-sites:/storage/sites:rw
@@ -462,7 +465,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
volumes:
- appwrite-config:/storage/config:rw
- appwrite-certificates:/storage/certificates:rw
@@ -499,7 +502,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
- openruntimes-executor
environment:
- _APP_ENV
@@ -624,7 +627,7 @@ $image = $this->getParam('image', '');
volumes:
- appwrite-imports:/storage/imports:rw
depends_on:
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -698,7 +701,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -727,7 +730,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -755,7 +758,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- mariadb
- <?= $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -782,7 +785,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- mariadb
- <?= $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -807,7 +810,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- mariadb
- <?= $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -832,7 +835,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- mariadb
- <?= $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -918,6 +921,7 @@ $image = $this->getParam('image', '');
- OPR_EXECUTOR_STORAGE_WASABI_REGION=$_APP_STORAGE_WASABI_REGION
- OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET
<?php if ($dbService === 'mariadb'): ?>
mariadb:
image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p
container_name: appwrite-mariadb
@@ -927,6 +931,8 @@ $image = $this->getParam('image', '');
- appwrite
volumes:
- appwrite-mariadb:/var/lib/mysql:rw
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=${_APP_DB_ROOT_PASS}
- MYSQL_DATABASE=${_APP_DB_SCHEMA}
@@ -934,6 +940,24 @@ $image = $this->getParam('image', '');
- MYSQL_PASSWORD=${_APP_DB_PASS}
- MARIADB_AUTO_UPGRADE=1
command: 'mysqld --innodb-flush-method=fsync'
<?php else: ?>
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
ports:
- "27017:27017"
environment:
- MONGO_INITDB_DATABASE=${_APP_DB_SCHEMA}
entrypoint: ["/bin/bash", "/mongo-entrypoint.sh"]
<?php endif; ?>
redis:
image: redis:7.2.4-alpine
@@ -968,7 +992,12 @@ networks:
name: runtimes
volumes:
<?php if ($dbService === 'mariadb'): ?>
appwrite-mariadb:
<?php else: ?>
appwrite-mongodb:
appwrite-mongodb-config:
<?php endif; ?>
appwrite-redis:
appwrite-cache:
appwrite-uploads:
+832
View File
@@ -0,0 +1,832 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$lockedDatabase = $lockedDatabase ?? null;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo $isUpgrade ? 'Appwrite Upgrade' : 'Appwrite Installation'; ?></title>
<link rel="stylesheet" href="https://unpkg.com/@appwrite.io/pink" />
<link rel="stylesheet" href="https://unpkg.com/@appwrite.io/pink-icons" />
<style>
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
font-family: var(--content-font, 'Inter', sans-serif);
}
.installer-container {
font-family: var(--content-font, 'Inter', sans-serif);
}
.installer-header h1 {
font-family: var(--heading-font, 'Poppins', sans-serif);
font-weight: 400;
letter-spacing: -0.144px;
}
.installer-header p {
font-family: var(--content-font, 'Inter', sans-serif);
}
.theme-toggle {
position: fixed;
top: 1.5rem;
right: 1.5rem;
z-index: 1000;
}
.installer-container {
width: 100%;
max-width: 540px;
}
.installer-header {
text-align: center;
margin-block-end: 2rem;
}
.installer-logo {
width: 64px;
height: 64px;
margin: 0 auto 1.5rem;
}
.step {
display: none;
}
.step.active {
display: block;
}
.step-section {
padding-block: 1.5rem;
border-block-start: 1px solid hsl(var(--color-border));
}
.step-section:first-of-type {
border-block-start: none;
padding-block-start: 0;
}
.section-header {
margin-block-end: 1rem;
}
.database-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.database-option {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
}
.database-option input[type="radio"] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.database-option:hover {
border-color: hsl(var(--color-primary-100));
}
.database-option.selected {
border-color: hsl(var(--color-primary-100));
background: hsl(var(--color-primary-100) / 0.04);
}
.database-logo {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: 0.75rem;
}
.database-logo img {
width: 100%;
height: 100%;
}
.installation-log {
max-height: 300px;
overflow-y: auto;
padding: 1rem;
background: hsl(var(--color-neutral-100));
border: 1px solid hsl(var(--color-border));
border-radius: var(--border-radius-small);
font-family: var(--code-font);
font-size: 0.8125rem;
line-height: 1.6;
}
.success-icon {
font-size: 4rem;
margin-block-end: 1rem;
}
.next-steps {
padding: 1.25rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--border-radius-small);
text-align: left;
margin-block-start: 1.5rem;
}
.next-steps ol {
padding-inline-start: 1.5rem;
line-height: 1.8;
margin-block-start: 0.75rem;
}
.next-steps li {
margin-block-end: 0.5rem;
}
#reviewContent {
padding: 1.25rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--border-radius-small);
}
#reviewContent > div {
line-height: 2;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s ease-in-out infinite;
margin-inline-end: 0.5rem;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-block-start: 2rem;
}
.input-text-wrapper.is-with-end-button {
position: relative;
}
.input-text-wrapper.is-with-end-button .input-text {
padding-right: 2.75rem;
}
.input-text-wrapper.is-with-end-button .button {
position: absolute;
right: 0.375rem;
top: 50%;
transform: translateY(-50%);
padding: 0.375rem;
margin: 0;
min-width: auto;
height: auto;
}
</style>
</head>
<body class="theme-dark">
<div class="theme-toggle">
<button class="button is-only-icon is-secondary" onclick="toggleTheme()" aria-label="Toggle theme" title="Toggle theme">
<span class="icon-sun u-only-light" aria-hidden="true"></span>
<span class="icon-moon u-only-dark" aria-hidden="true"></span>
</button>
</div>
<div class="installer-container">
<article class="card">
<div class="installer-header">
<div class="installer-logo">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_28_719)">
<path d="M24.4429 17.4322V22.9096H10.7519C6.76318 22.9096 3.28044 20.7067 1.4171 17.4322C1.14622 16.9561 0.909137 16.4567 0.710264 15.9383C0.319864 14.9225 0.0744552 13.8325 0 12.6952V11.2143C0.0161646 10.9609 0.0416361 10.7094 0.0749451 10.4609C0.143032 9.95105 0.245898 9.45211 0.381093 8.96711C1.66006 4.36909 5.81877 1 10.7519 1C15.6851 1 19.8433 4.36909 21.1223 8.96711H15.2682C14.3072 7.4683 12.6437 6.4774 10.7519 6.4774C8.86017 6.4774 7.19668 7.4683 6.23562 8.96711C5.9427 9.42274 5.71542 9.92516 5.56651 10.4609C5.43425 10.936 5.36371 11.4369 5.36371 11.9548C5.36371 13.5248 6.01324 14.94 7.05463 15.9383C8.01961 16.865 9.32061 17.4322 10.7519 17.4322H24.4429Z" fill="#FD366E" />
<path d="M24.4429 10.4609V15.9383H14.4492C15.4906 14.94 16.1401 13.5248 16.1401 11.9548C16.1401 11.4369 16.0696 10.936 15.9373 10.4609H24.4429Z" fill="#FD366E" />
</g>
<defs>
<clipPath id="clip0_28_719">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<h1 class="heading-level-4"><?php echo $isUpgrade ? 'Appwrite Upgrade' : 'Appwrite Installation'; ?></h1>
<p class="body-text-2 u-margin-block-start-8"><?php echo $isUpgrade ? 'Upgrade your Appwrite instance to the latest version' : 'Let\'s get your Appwrite instance up and running'; ?></p>
</div>
<section class="progress-bar u-margin-block-end-32">
<div class="progress-bar-container" id="progressBar" style="--graph-size:17%"></div>
</section>
<p class="body-text-2 u-text-center u-margin-block-end-16" id="wizardStepLabel">Step 1 of 6</p>
<div id="alertContainer" class="u-margin-block-end-24"></div>
<!-- Step 1: Server Configuration -->
<div class="step active" data-step="1">
<div class="step-section">
<div class="section-header">
<h2 class="eyebrow-heading-3 u-margin-block-end-4">Configuration</h2>
</div>
<form class="form u-width-full-line">
<ul class="form-list">
<li class="form-item">
<label class="label" for="httpPort">HTTP port</label>
<div class="input-text-wrapper">
<input type="text" id="httpPort" class="input-text" value="<?php echo $defaultHttpPort; ?>" placeholder="80">
</div>
<p class="helper u-margin-block-start-4">The port for HTTP connections (default: 80)</p>
</li>
<li class="form-item">
<label class="label" for="httpsPort">HTTPS port</label>
<div class="input-text-wrapper">
<input type="text" id="httpsPort" class="input-text" value="<?php echo $defaultHttpsPort; ?>" placeholder="443">
</div>
<p class="helper u-margin-block-start-4">The port for HTTPS connections (default: 443)</p>
</li>
</ul>
</form>
</div>
<div class="button-group">
<button class="button" id="primaryButton" onclick="nextStep()">
<span class="text">Next</span>
</button>
</div>
</div>
<!-- Step 2: Database Selection -->
<?php if (!$isUpgrade): ?>
<div class="step" data-step="2">
<div class="step-section">
<div class="section-header">
<h2 class="eyebrow-heading-3 u-margin-block-end-4">Database</h2>
<p class="body-text-2">Choose your preferred database engine</p>
</div>
<div class="database-options">
<label class="card is-allow-focus database-option selected" data-database="mongodb">
<input type="radio" name="database" value="mongodb" checked>
<div class="database-logo">
<img src="/images/logos/mongodb.svg" alt="MongoDB logo" />
</div>
<div class="u-bold u-margin-block-end-4">MongoDB</div>
<div class="body-text-2 u-color-text-offline">Document based database</div>
</label>
<label class="card is-allow-focus database-option" data-database="mariadb">
<input type="radio" name="database" value="mariadb">
<div class="database-logo">
<img src="/images/logos/mariadb.svg" alt="MariaDB logo" />
</div>
<div class="u-bold u-margin-block-end-4">MariaDB</div>
<div class="body-text-2 u-color-text-offline">Relational SQL database</div>
</label>
</div>
</div>
<div class="button-group">
<button class="button is-secondary" id="backButton" onclick="prevStep()">
<span class="text">Back</span>
</button>
<button class="button" id="primaryButton" onclick="nextStep()">
<span class="text">Next</span>
</button>
</div>
</div>
<?php endif; ?>
<!-- Step 3: Domain Configuration -->
<div class="step" data-step="3">
<div class="step-section">
<div class="section-header">
<h2 class="eyebrow-heading-3 u-margin-block-end-4">Domain</h2>
</div>
<form class="form u-width-full-line">
<ul class="form-list">
<li class="form-item">
<label class="label" for="appDomain">Appwrite hostname</label>
<div class="input-text-wrapper">
<input type="text" id="appDomain" class="input-text" placeholder="localhost">
</div>
<p class="helper u-margin-block-start-4">Your Appwrite domain address (e.g., appwrite.example.com)</p>
</li>
<li class="form-item">
<label class="label" for="emailCertificates">SSL certificate email</label>
<div class="input-text-wrapper">
<input type="email" id="emailCertificates" class="input-text" placeholder="admin@example.com">
</div>
<p class="helper u-margin-block-start-4">Email address for SSL certificate registration</p>
</li>
</ul>
</form>
</div>
<div class="button-group">
<button class="button is-secondary" id="backButton" onclick="prevStep()">
<span class="text">Back</span>
</button>
<button class="button" id="primaryButton" onclick="nextStep()">
<span class="text">Next</span>
</button>
</div>
</div>
<!-- Step 4: Security Configuration -->
<div class="step" data-step="4">
<div class="step-section">
<div class="section-header">
<h2 class="eyebrow-heading-3 u-margin-block-end-4">Security</h2>
</div>
<form class="form u-width-full-line">
<ul class="form-list">
<li class="form-item">
<label class="label" for="opensslKey">Secret API key</label>
<div class="input-text-wrapper is-with-end-button u-width-full-line">
<input type="text" id="opensslKey" class="input-text" placeholder="Enter or generate a secure key">
<button type="button" class="button is-text is-only-icon" onclick="generateSecretKey(); event.preventDefault();">
<span class="icon-refresh" aria-hidden="true"></span>
</button>
</div>
<p class="helper u-margin-block-start-4">Your server private secret key for encryption. Keep this safe!</p>
</li>
</ul>
</form>
<div class="alert is-info u-margin-block-start-16">
<p class="body-text-2"><strong>Note:</strong> Use the Generate button for a secure random key, or provide your own. Make sure to back it up in a secure location.</p>
</div>
</div>
<div class="button-group">
<button class="button is-secondary" id="backButton" onclick="prevStep()">
<span class="text">Back</span>
</button>
<button class="button" id="primaryButton" onclick="nextStep()">
<span class="text">Review & Install</span>
</button>
</div>
</div>
<!-- Step 5: Review and Install -->
<div class="step" data-step="5">
<div class="step-section">
<div class="section-header">
<h2 class="eyebrow-heading-3 u-margin-block-end-4">Review</h2>
<p class="body-text-2">Please review your settings before installation</p>
</div>
<div id="reviewContent"></div>
</div>
<div class="button-group">
<button class="button is-secondary" id="reviewBackButton" onclick="prevStep()">
<span class="text">Back</span>
</button>
<button class="button" id="installButton" onclick="startInstallation()">
<span class="text" id="installButtonText"><?php echo $isUpgrade ? 'Upgrade Appwrite' : 'Install Appwrite'; ?></span>
</button>
</div>
<div id="installationLog" class="installation-log u-margin-block-start-24" style="display: none;"></div>
</div>
<!-- Step 6: Complete -->
<div class="step" data-step="6">
<div class="u-text-center">
<h2 class="heading-level-5 u-margin-block-end-8"><?php echo $isUpgrade ? 'Upgrade complete' : 'Installation complete'; ?></h2>
<p class="body-text-2">Your Appwrite instance has been successfully <?php echo $isUpgrade ? 'upgraded' : 'installed'; ?> and is now running.</p>
<div class="next-steps u-margin-block-start-24">
<p class="body-text-1 u-bold u-margin-block-end-8">Next Steps:</p>
<ol class="body-text-2">
<li>Access your Appwrite console at: <strong style="color: hsl(var(--p-primary-100-hsl))">http://<?php echo $defaultHttpPort === '80' ? 'localhost' : 'localhost:'.$defaultHttpPort; ?>/console</strong></li>
<li>Create your first project and start building!</li>
</ol>
</div>
<div class="button-group">
<button class="button" onclick="window.close()">
<span class="text">Close window</span>
</button>
</div>
</div>
</div>
</article>
</div>
<script>
const isUpgrade = <?php echo $isUpgrade ? 'true' : 'false'; ?>;
const lockedDatabase = <?php echo $lockedDatabase ? "'" . $lockedDatabase . "'" : 'null'; ?>;
const existingVars = <?php echo json_encode($vars ?? []); ?>;
let currentStep = 1;
const totalSteps = 6;
const skipSteps = isUpgrade ? [2, 4] : []; // Skip database selection and security during upgrade
let isInstalling = false;
// Helper function to get existing value or default
function getExistingValue(key, defaultValue = '') {
return existingVars[key]?.default ?? defaultValue;
}
const formData = {
httpPort: '<?php echo $defaultHttpPort; ?>',
httpsPort: '<?php echo $defaultHttpsPort; ?>',
database: lockedDatabase || 'mongodb',
appDomain: getExistingValue('_APP_DOMAIN', 'localhost'),
emailCertificates: getExistingValue('_APP_EMAIL_CERTIFICATES', ''),
opensslKey: getExistingValue('_APP_OPENSSL_KEY_V1', ''),
organization: 'appwrite',
image: 'appwrite'
};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
populateFormFields();
// For upgrades, preserve existing key in formData (but step 4 is skipped)
// For fresh installs, leave key empty - user can generate or enter manually
updateProgress();
updateButtonIds();
initializeDatabaseSelection();
initializeTheme();
});
function populateFormFields() {
// Populate all form fields with existing values
const httpPortInput = document.getElementById('httpPort');
const httpsPortInput = document.getElementById('httpsPort');
const appDomainInput = document.getElementById('appDomain');
const emailCertificatesInput = document.getElementById('emailCertificates');
if (httpPortInput) httpPortInput.value = formData.httpPort;
if (httpsPortInput) httpsPortInput.value = formData.httpsPort;
if (appDomainInput) appDomainInput.value = formData.appDomain;
if (emailCertificatesInput) emailCertificatesInput.value = formData.emailCertificates;
}
function initializeTheme() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(theme);
}
function toggleTheme() {
const body = document.body;
const currentTheme = body.classList.contains('theme-dark') ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
function setTheme(theme) {
const body = document.body;
if (theme === 'dark') {
body.classList.remove('theme-light');
body.classList.add('theme-dark');
} else {
body.classList.remove('theme-dark');
body.classList.add('theme-light');
}
localStorage.setItem('theme', theme);
}
function initializeDatabaseSelection() {
const databaseOptions = document.querySelectorAll('.database-option');
databaseOptions.forEach(option => {
option.addEventListener('click', function() {
databaseOptions.forEach(opt => opt.classList.remove('selected'));
this.classList.add('selected');
this.querySelector('input[type="radio"]').checked = true;
formData.database = this.dataset.database;
});
});
}
function generateSecretKey() {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
const key = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
const input = document.getElementById('opensslKey');
if (input) {
input.value = key;
}
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alertContainer');
const alert = document.createElement('div');
alert.className = `alert is-${type}`;
alert.innerHTML = `<p class="body-text-2">${message}</p>`;
container.innerHTML = '';
container.appendChild(alert);
if (type !== 'error') {
setTimeout(() => {
alert.remove();
}, 5000);
}
}
function clearAlerts() {
document.getElementById('alertContainer').innerHTML = '';
}
function validateStep(step) {
clearAlerts();
switch(step) {
case 1:
const httpPort = document.getElementById('httpPort').value;
const httpsPort = document.getElementById('httpsPort').value;
if (!httpPort || isNaN(httpPort) || httpPort < 1 || httpPort > 65535) {
showAlert('Please enter a valid HTTP port (1-65535)', 'error');
return false;
}
if (!httpsPort || isNaN(httpsPort) || httpsPort < 1 || httpsPort > 65535) {
showAlert('Please enter a valid HTTPS port (1-65535)', 'error');
return false;
}
formData.httpPort = httpPort;
formData.httpsPort = httpsPort;
return true;
case 2:
return true;
case 3:
const appDomain = document.getElementById('appDomain').value;
const emailCertificates = document.getElementById('emailCertificates').value;
if (!appDomain || appDomain.trim() === '') {
showAlert('Please enter your Appwrite hostname', 'error');
return false;
}
if (!emailCertificates || emailCertificates.trim() === '') {
showAlert('Please enter an email address for SSL certificates', 'error');
return false;
}
if (!isValidEmail(emailCertificates)) {
showAlert('Please enter a valid email address', 'error');
return false;
}
formData.appDomain = appDomain;
formData.emailCertificates = emailCertificates;
return true;
case 4:
const opensslKey = document.getElementById('opensslKey').value;
if (!opensslKey || opensslKey.trim() === '') {
showAlert('Please enter or generate a secret API key.', 'error');
return false;
}
if (opensslKey.length < 32) {
showAlert('Secret API key must be at least 32 characters long.', 'error');
return false;
}
formData.opensslKey = opensslKey;
return true;
case 5:
return true;
default:
return true;
}
}
function isValidEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
function nextStep() {
if (!validateStep(currentStep)) {
return;
}
if (currentStep < totalSteps) {
document.querySelector(`.step[data-step="${currentStep}"]`)?.classList.remove('active');
currentStep++;
// Skip steps that should be skipped (e.g., database selection during upgrade)
while (skipSteps.includes(currentStep) && currentStep < totalSteps) {
currentStep++;
}
document.querySelector(`.step[data-step="${currentStep}"]`)?.classList.add('active');
updateProgress();
updateButtonIds();
if (currentStep === 4) {
// Auto-generate secret key if not already set
const opensslKeyInput = document.getElementById('opensslKey');
if (opensslKeyInput && (!opensslKeyInput.value || opensslKeyInput.value.trim() === '')) {
generateSecretKey();
}
}
if (currentStep === 5) {
populateReview();
}
window.scrollTo(0, 0);
}
}
function prevStep() {
if (isInstalling) {
return; // Don't allow going back during installation
}
if (currentStep > 1) {
document.querySelector(`.step[data-step="${currentStep}"]`)?.classList.remove('active');
currentStep--;
// Skip steps that should be skipped (e.g., database selection during upgrade)
while (skipSteps.includes(currentStep) && currentStep > 1) {
currentStep--;
}
document.querySelector(`.step[data-step="${currentStep}"]`)?.classList.add('active');
updateProgress();
updateButtonIds();
window.scrollTo(0, 0);
}
}
function updateProgress() {
const progress = Math.round((currentStep / totalSteps) * 100);
document.getElementById('progressBar').style.setProperty('--graph-size', `${progress}%`);
// Update step label
const stepLabel = document.getElementById('wizardStepLabel');
if (stepLabel) {
stepLabel.textContent = `Step ${currentStep} of ${totalSteps}`;
}
}
function updateButtonIds() {
// Remove IDs from buttons that have primaryButton or backButton IDs only
document.querySelectorAll('#primaryButton, #backButton').forEach(btn => {
btn.removeAttribute('id');
});
// Add IDs to buttons in the current active step
// Skip buttons that already have specific IDs (like installButton, reviewBackButton)
const activeStep = document.querySelector(`.step[data-step="${currentStep}"]`);
if (activeStep) {
const buttons = activeStep.querySelectorAll('.button-group .button');
buttons.forEach((button, index) => {
// Don't override existing specific button IDs
if (button.id && button.id !== 'primaryButton' && button.id !== 'backButton') {
return;
}
if (button.classList.contains('is-secondary')) {
button.id = 'backButton';
} else {
button.id = 'primaryButton';
}
});
}
}
function populateReview() {
const reviewContent = document.getElementById('reviewContent');
const databaseName = formData.database === 'mongodb' ? 'MongoDB' : 'MariaDB';
reviewContent.innerHTML = `
<div class="body-text-2" style="line-height: 2;">
<strong>HTTP Port:</strong> ${formData.httpPort}<br>
<strong>HTTPS Port:</strong> ${formData.httpsPort}<br>
<strong>Database:</strong> ${databaseName}<br>
<strong>Appwrite Hostname:</strong> ${formData.appDomain}<br>
<strong>SSL Certificate Email:</strong> ${formData.emailCertificates}<br>
<strong>Secret Key:</strong> ${formData.opensslKey.substring(0, 16)}... (hidden)
</div>
`;
}
async function startInstallation() {
const button = document.getElementById('installButton');
const buttonText = document.getElementById('installButtonText');
const logContainer = document.getElementById('installationLog');
const backButton = document.getElementById('reviewBackButton');
isInstalling = true;
button.disabled = true;
if (backButton) {
backButton.disabled = true;
}
buttonText.innerHTML = `<span class="spinner"></span> ${isUpgrade ? 'Upgrading...' : 'Installing...'}`;
logContainer.style.display = 'block';
logContainer.innerHTML = '';
try {
addLog(`Starting Appwrite ${isUpgrade ? 'upgrade' : 'installation'}...`);
const response = await fetch('/install', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const result = await response.json();
throw new Error(result.message || `Installation failed: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
addLog('✓ Configuration files created successfully');
addLog('✓ Docker Compose file generated');
addLog('✓ Environment variables configured');
addLog('✓ Docker containers started');
addLog(`${isUpgrade ? 'Upgrade' : 'Installation'} completed successfully!`);
setTimeout(() => {
document.querySelector(`.step[data-step="${currentStep}"]`).classList.remove('active');
currentStep = 6;
document.querySelector(`.step[data-step="${currentStep}"]`).classList.add('active');
updateProgress();
}, 3000);
} else {
throw new Error(result.message || 'Installation failed');
}
} catch (error) {
console.error(`${isUpgrade ? 'Upgrade' : 'Installation'} error:`, error);
addLog(`✗ Error: ${error.message}`, 'error');
showAlert(`${isUpgrade ? 'Upgrade' : 'Installation'} failed: ${error.message}`, 'error');
// Re-enable buttons on error
isInstalling = false;
button.disabled = false;
if (backButton) {
backButton.disabled = false;
}
buttonText.textContent = `Retry ${isUpgrade ? 'Upgrade' : 'Installation'}`;
}
}
function addLog(message, type = 'info') {
const logContainer = document.getElementById('installationLog');
const logEntry = document.createElement('div');
logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
if (type === 'error') {
logEntry.style.color = '#ff6b6b';
} else if (type === 'success') {
logEntry.style.color = '#51cf66';
}
logContainer.appendChild(logEntry);
logContainer.scrollTop = logContainer.scrollHeight;
}
window.addEventListener('popstate', (event) => {
if (currentStep > 1 && currentStep < 6) {
prevStep();
}
});
</script>
</body>
</html>
Generated
+6 -6
View File
@@ -6663,16 +6663,16 @@
},
{
"name": "phpunit/phpunit",
"version": "9.6.29",
"version": "9.6.30",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3"
"reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
"reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b69489b312503bf8fa6d75a76916919d7d2fa6d4",
"reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4",
"shasum": ""
},
"require": {
@@ -6746,7 +6746,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29"
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.30"
},
"funding": [
{
@@ -6770,7 +6770,7 @@
"type": "tidelift"
}
],
"time": "2025-09-24T06:29:11+00:00"
"time": "2025-12-01T07:35:08+00:00"
},
{
"name": "psr/cache",
+37
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
{
"name": "@appwrite.io/repo",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@appwrite.io/repo",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.56.0"
}
},
"node_modules/@playwright/test": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.56.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+26 -1
View File
@@ -8,5 +8,30 @@
"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"
},
"scripts": {
"test": "playwright test",
"test:installer": "playwright test tests/playwright/installer.spec.js",
"test:screenshots": "playwright test tests/playwright/installer-screenshots.spec.js --project=chromium",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/appwrite/appwrite/issues"
},
"homepage": "https://github.com/appwrite/appwrite#readme",
"devDependencies": {
"@playwright/test": "^1.56.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
/**
* @see https://playwright.dev/docs/test-configuration
*/
module.exports = defineConfig({
testDir: './tests/playwright',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:8080',
trace: 'on-first-retry',
screenshot: 'on',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 45.025 44.217"><defs><clipPath id="A"><use xlink:href="#B"/></clipPath><path id="B" d="M0 0h191.356v57.344H0z"/></defs><path clip-path="url(#A)" d="M75.198 5.328c-1.872.047-2.977.863-3.34 2.42.48.418 1.126.6 1.77.572 1.415-.06 2.817-1.138 2.42-2.965-.298-.025-.582-.035-.85-.027M84.84 0h-.167c-1.335.043-1.542.767-4.425 1.477-2.912.717-5.725.028-8.86 1.344C62.025 6.748 60.03 20.293 51.52 25.1c-6.36 3.593-12.512 5.188-18.28 6.995-3.792 1.188-7.552 2.556-10.988 5.516-2.667 2.3-3.4 4.085-6.177 6.97-2.835 2.933-11.553-2.258-15.8 1.54a.84.84 0 0 0 .014 1.253c.92.805 2.67 2.722 4.336 3.314-.528 1-3.513 3.946-2.904 5.42.64 1.55 8.04.497 14.88-3.633 3.185-1.924 5.723-4.696 10.685-5.357 6.42-.855 13.817.55 21.25 1.62-1.103 3.286-3.315 5.473-5.088 8.1-.55.592 1.103.658 2.986.3 3.4-.838 5.832-1.513 8.4-3 3.143-1.83 3.62-6.52 7.474-7.534 1.864 2.865 6.5 3.83 10.074 2.3.198-.084.392-.177.582-.275.43-.375.178-.813-.086-1.135a6.21 6.21 0 0 1-.362-.364c-2.012-2.162-2.472-7.266-1.58-9.748 1.017-2.824 2.022-7.343 3.046-11.075 1.1-4 1.506-9.063 2.837-11.106 2.002-3.072 4.215-4.127 6.136-5.86s3.68-3.42 3.62-7.384C86.547.734 85.946.03 84.84 0m.204 4.163C84.647 6.897 82.9 8.4 80.836 9.85c-1.8 1.27-3.794 2.493-5.07 4.477-1.305 2.03-2.134 8.985-4.16 15.852-1.68 5.698-4.184 11.335-8.467 14.05-.278.176-.645.015-.704-.3-.3-1.592-.24-4.5-.734-3.56-.62 1.77-1.322 3.458-2.143 5.027-2.52 4.816-6.166 8.505-12.057 9.95a.53.53 0 0 1-.552-.82c2.718-3.77 5.15-7.825 5.447-14.014.025-.534-.646-.778-.983-.364-1.284 1.583-1.6 5.347-3.477 6.506-1.474.16-2.967.16-4.47.07-6.17-.37-12.502-2.226-18.274-.373-3.93 1.262-8.057 4.85-11.386 6.293C9.894 54.33 8.04 55.93 3.1 55.82c-.505-.68 2.062-3.623 2.934-4.893.278-.407-.317-.835-.874-1.1-1.338-.614-2.68-2.28-4.107-2.93.183-.337.83-.674 1.187-.88 3.24-1.88 11.832 2.124 14.14-.143 1.425-1.398 2.385-2.626 3.353-4.05.94-1.38 2.368-2.838 3.847-4.047a31.3 31.3 0 0 1 1.94-1.435c2.52-1.724 3.907-1.852 7.17-3.064 4.152-1.544 9.293-2.898 13.747-4.6 2.752-1.053 5.744-2.35 8.183-4.17.58-.432 1.127-.893 1.634-1.386 6.964-6.8 8.345-18.766 19.2-19.882 1.314-.135 2.248-.192 3.228-.223 1.096-.03 2.18-.3 3.263-.818.334-.146 2.324-1.35 2.996-.768.448.388.115 2.492.086 2.72" transform="matrix(.52012 0 0 .52012 0 7.19564)" fill="#444b5e"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32"><path d="M15.9.087l.854 1.604c.192.296.4.558.645.802.715.715 1.394 1.464 2.004 2.266 1.447 1.9 2.423 4.01 3.12 6.292.418 1.394.645 2.824.662 4.27.07 4.323-1.412 8.035-4.4 11.12-.488.488-1.01.94-1.57 1.342-.296 0-.436-.227-.558-.436-.227-.383-.366-.82-.436-1.255-.105-.523-.174-1.046-.14-1.586v-.244C16.057 24.21 15.796.21 15.9.087z" fill="#599636"/><path d="M15.9.034c-.035-.07-.07-.017-.105.017.017.35-.105.662-.296.96-.21.296-.488.523-.767.767-1.55 1.342-2.77 2.963-3.747 4.776-1.3 2.44-1.97 5.055-2.16 7.808-.087.993.314 4.497.627 5.508.854 2.684 2.388 4.933 4.375 6.885.488.47 1.01.906 1.55 1.325.157 0 .174-.14.21-.244a4.78 4.78 0 0 0 .157-.68l.35-2.614L15.9.034z" fill="#6cac48"/><path d="M16.754 28.845c.035-.4.227-.732.436-1.063-.21-.087-.366-.26-.488-.453-.105-.174-.192-.383-.26-.575-.244-.732-.296-1.5-.366-2.248v-.453c-.087.07-.105.662-.105.75a17.37 17.37 0 0 1-.314 2.353c-.052.314-.087.627-.28.906 0 .035 0 .07.017.122.314.924.4 1.865.453 2.824v.35c0 .418-.017.33.33.47.14.052.296.07.436.174.105 0 .122-.087.122-.157l-.052-.575v-1.604c-.017-.28.035-.558.07-.82z" fill="#c2bfbf"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -102,11 +102,9 @@ class Get extends Action
// Use transaction-aware document retrieval if transactionId is provided
if ($transactionId !== null) {
$document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries);
} elseif (! empty($selects)) {
// has selects, allow relationship on documents!
} elseif (!empty($selects)) {
$document = $dbForProject->getDocument($collectionTableId, $documentId, $queries);
} else {
// has no selects, disable relationship looping on documents!
$document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries));
}
} catch (QueryException $e) {
@@ -16,6 +16,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Exception\Timeout;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
@@ -150,6 +151,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;
+423 -73
View File
@@ -12,10 +12,12 @@ use Utopia\Config\Config;
use Utopia\Platform\Action;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Install extends Action
{
protected string $path = '/usr/src/code/appwrite';
protected ?string $hostPath = null;
public static function getName(): string
{
@@ -33,6 +35,9 @@ class Install extends Action
->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(...));
$this->hostPath = $this->detectHostPath($this->path);
$this->ensureHostPathLink();
}
public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void
@@ -40,7 +45,7 @@ class Install extends Action
$config = Config::getParam('variables');
$defaultHTTPPort = '80';
$defaultHTTPSPort = '443';
/** @var array<string, array<string, string>> $vars array whre key is variable name and value is variable */
/** @var array<string, array<string, string>> $vars array where key is variable name and value is variable */
$vars = [];
foreach ($config as $category) {
@@ -59,18 +64,11 @@ class Install extends Action
}
}
// Check for existing installation
$data = @file_get_contents($this->path . '/docker-compose.yml');
$existingInstallation = $data !== false;
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;
}
}
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);
@@ -88,7 +86,7 @@ class Install extends Action
}
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 +105,12 @@ class Install extends Action
}
}
$data = @file_get_contents($this->path . '/.env');
$envData = @file_get_contents($this->path . '/.env');
if ($data !== false) { // Fetch all env vars from previous .env file
if ($envData !== false) {
Console::info('Env file found, creating backup: .env.' . $time . '.backup');
file_put_contents($this->path . '/.env.' . $time . '.backup', $data);
$env = new Env($data);
file_put_contents($this->path . '/.env.' . $time . '.backup', $envData);
$env = new Env($envData);
foreach ($env->list() as $key => $value) {
if (is_null($value)) {
@@ -138,6 +136,17 @@ class Install extends Action
}
}
// 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:8080');
Console::info('Press Ctrl+C to cancel installation');
$this->startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars);
return;
}
// Fall back to CLI mode
if (empty($httpPort)) {
$httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')');
$httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort;
@@ -148,97 +157,438 @@ class Install extends Action
$httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort;
}
$input = [];
$password = new Password();
$token = new Token();
foreach ($vars as $var) {
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", "@", "<YOUR PUBLIC IP>");
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", "@", "<YOUR PUBLIC IP>");
Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n");
}
}
$input = $this->prepareEnvironmentVariables($userInput, $vars);
$this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart);
}
private function detectHostPath(string $path): ?string
{
if (!is_file('/proc/self/mountinfo')) {
return null;
}
$bestMatch = null;
$bestLength = 0;
foreach (@file('/proc/self/mountinfo') as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$parts = explode(' - ', $line, 2);
if (count($parts) < 2) {
continue;
}
$left = preg_split('/\s+/', $parts[0]);
$right = preg_split('/\s+/', $parts[1]);
if (count($left) < 5 || count($right) < 2) {
continue;
}
$mountPoint = $left[4];
$root = $left[3];
$fsType = $right[0];
$source = $right[1];
if (!str_starts_with($path, $mountPoint)) {
continue;
}
$mountLength = strlen($mountPoint);
if ($mountLength < $bestLength) {
continue;
}
$relative = substr($path, $mountLength);
$relative = ltrim($relative, '/');
$hostBase = null;
if ($fsType === 'fakeowner' && str_starts_with($source, '/run/host_mark/')) {
$share = basename($source);
$hostBase = '/' . $share . rtrim($root, '/');
} elseif ($root !== '/' && $root !== '.') {
$hostBase = $root;
} elseif ($source !== 'none') {
$hostBase = $source;
}
if (!$hostBase || $hostBase === '.') {
continue;
}
if ($hostBase[0] !== '/') {
$hostBase = '/' . $hostBase;
}
$candidate = rtrim($hostBase, '/');
if ($relative !== '') {
$candidate .= '/' . $relative;
}
$candidate = preg_replace('#//+#', '/', $candidate);
$bestMatch = $candidate;
$bestLength = $mountLength;
}
return $bestMatch;
}
private function ensureHostPathLink(): void
{
if (empty($this->hostPath) || $this->hostPath === $this->path) {
return;
}
if (is_link($this->hostPath)) {
return;
}
if (is_dir($this->hostPath)) {
@rmdir($this->hostPath);
}
$parent = dirname($this->hostPath);
if (!is_dir($parent)) {
@mkdir($parent, 0755, true);
}
if (!file_exists($this->hostPath)) {
@symlink($this->path, $this->hostPath);
}
}
protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
{
$port = 8080;
$host = '0.0.0.0';
$url = "http://localhost:$port";
// Create a router script for handling requests
$routerScript = \sys_get_temp_dir() . '/appwrite-installer-router.php';
$this->createRouterScript($routerScript, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, $isUpgrade, $lockedDatabase);
// Start PHP built-in server in background
$command = \sprintf(
'php -S %s:%d %s >/dev/null 2>&1 &',
$host,
$port,
\escapeshellarg($routerScript)
);
// Start the server
\exec($command, $output, $exitCode);
\sleep(3);
// Check if the server actually started
$handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
if ($handle === false) {
Console::exit(1);
}
\fclose($handle);
// Wait for the server process to finish
while (true) {
$handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
if ($handle === false) {
// Server has stopped
break;
}
\fclose($handle);
\sleep(1);
}
// Cleanup
if (\file_exists($routerScript)) {
\unlink($routerScript);
}
}
private function createRouterScript(string $path, string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
{
$installPhpPath = __FILE__;
$appViewsPath = __DIR__ . '/../../../../app/views/install';
$publicPath = __DIR__ . '/../../../../public';
$vendorPath = __DIR__ . '/../../../../vendor/autoload.php';
$appwritePath = __DIR__ . '/../../../../app/init.php';
$script = <<<'PHP'
<?php
require_once '%s';
require_once '%s';
define('INSTALL_PHP_PATH', '%s');
define('APP_VIEWS_PATH', '%s');
define('PUBLIC_PATH', '%s');
define('DEFAULT_HTTP_PORT', '%s');
define('DEFAULT_HTTPS_PORT', '%s');
define('ORGANIZATION', '%s');
define('IMAGE', '%s');
define('NO_START', %s);
define('VARS_JSON', '%s');
define('IS_UPGRADE', %s);
define('LOCKED_DATABASE', %s);
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
// Serve static files
if ($uri !== '/' && $uri !== '') {
$filePath = PUBLIC_PATH . $uri;
if (file_exists($filePath) && is_file($filePath)) {
// Determine content type
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
$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',
];
$contentType = $mimeTypes[$extension] ?? 'application/octet-stream';
header('Content-Type: ' . $contentType);
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
exit;
}
}
// Handle POST request (installation)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') {
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid request']);
exit;
}
try {
require_once INSTALL_PHP_PATH;
$installer = new \Appwrite\Platform\Tasks\Install();
// Prepare user inputs - use locked database if in upgrade mode
$userInput = [
'_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' => LOCKED_DATABASE ?? ($input['database'] ?? 'mongodb'),
];
// Use the prepareEnvironmentVariables method to merge with defaults
$vars = json_decode(VARS_JSON, true);
$envVars = $installer->prepareEnvironmentVariables($userInput, $vars);
// Call performInstallation method
$installer->performInstallation(
$input['httpPort'] ?? DEFAULT_HTTP_PORT,
$input['httpsPort'] ?? DEFAULT_HTTPS_PORT,
ORGANIZATION,
IMAGE,
$envVars,
NO_START
);
echo json_encode(['success' => true, 'message' => 'Installation completed successfully']);
// Stop the server after successful installation
register_shutdown_function(function() {
sleep(2); // Give time for response to be sent
posix_kill(posix_getpid(), SIGTERM);
});
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
exit;
}
// Serve installer UI
if ($uri === '/' || $uri === '') {
header('Content-Type: text/html');
$vars = json_decode(VARS_JSON, true);
$defaultHttpPort = DEFAULT_HTTP_PORT;
$defaultHttpsPort = DEFAULT_HTTPS_PORT;
$isUpgrade = IS_UPGRADE;
$lockedDatabase = LOCKED_DATABASE;
include APP_VIEWS_PATH . '/installer.phtml';
exit;
}
// 404
http_response_code(404);
echo '404 Not Found';
PHP;
$varsJson = json_encode($vars, JSON_UNESCAPED_SLASHES);
$script = sprintf(
$script,
$vendorPath,
$appwritePath,
$installPhpPath,
$appViewsPath,
$publicPath,
$defaultHTTPPort,
$defaultHTTPSPort,
$organization,
$image,
var_export($noStart, true),
str_replace("'", "\\'", $varsJson),
var_export($isUpgrade, true),
var_export($lockedDatabase, true)
);
file_put_contents($path, $script);
}
public function prepareEnvironmentVariables(array $userInput, array $vars): array
{
$input = [];
$password = new Password();
$token = new Token();
// Start with all defaults
foreach ($vars as $var) {
if (!empty($var['filter']) && $var['filter'] === 'token') {
$input[$var['name']] = $token->generate();
} elseif (!empty($var['filter']) && $var['filter'] === 'password') {
$input[$var['name']] = $password->generate();
} else {
$input[$var['name']] = $var['default'];
}
}
// Override with user inputs
foreach ($userInput as $key => $value) {
if ($value !== null && $value !== '') {
$input[$key] = $value;
}
}
// 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 performInstallation(
string $httpPort,
string $httpsPort,
string $organization,
string $image,
array $input,
bool $noStart
): void {
// Check if we're running in CLI context
$isCLI = php_sapi_name() === 'cli';
$templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml');
$templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml');
$database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
$templateForCompose
->setParam('httpPort', $httpPort)
->setParam('httpsPort', $httpsPort)
->setParam('version', APP_VERSION_STABLE)
->setParam('version', 'next')
->setParam('organization', $organization)
->setParam('image', $image);
->setParam('image', $image)
->setParam('database', $database)
->setParam('hostPath', $this->hostPath);
$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);
if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) {
throw new \Exception('Failed to save Docker Compose file');
}
if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) {
$message = 'Failed to save environment variables file';
Console::error($message);
Console::exit(1);
if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) {
throw new \Exception('Failed to save environment variables file');
}
$env = '';
$stdout = '';
$stderr = '';
// Copy MongoDB entrypoint script for replica set setup
if ($database === 'mongodb') {
$mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh';
foreach ($input as $key => $value) {
if ($value) {
$env .= $key . '=' . \escapeshellarg($value) . ' ';
if (file_exists($mongoEntrypoint)) {
copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh');
}
}
$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);
}
$env = '';
foreach ($input as $key => $value) {
if ($value) {
$env .= $key . '=' . \escapeshellarg($value) . ' ';
}
}
if ($exit !== 0) {
$message = 'Failed to install Appwrite dockers';
Console::error($message);
Console::error($stderr);
Console::exit($exit);
if ($isCLI) {
Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
}
$command = "$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes 2>&1";
\exec($command, $output, $exit);
if ($exit !== 0) {
throw new \Exception('Failed to start Appwrite: ' . implode("\n", $output));
}
if ($isCLI) {
Console::success('Appwrite installed successfully');
}
} else {
$message = 'Appwrite installed successfully';
Console::success($message);
if ($isCLI) {
Console::success('Installation files created. Run "docker compose up -d" to start Appwrite');
}
}
}
}
+14 -1
View File
@@ -8,6 +8,8 @@ use Utopia\Validator\Text;
class Upgrade extends Install
{
private ?string $lockedDatabase = null;
public static function getName(): string
{
return 'upgrade';
@@ -15,6 +17,8 @@ class Upgrade extends Install
public function __construct()
{
parent::__construct();
$this
->desc('Upgrade Appwrite')
->param('http-port', '', new Text(4), 'Server HTTP port', true)
@@ -39,6 +43,15 @@ class Upgrade extends Install
Console::log(' └── docker-compose.yml');
Console::exit(1);
}
parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart);
$database = System::getEnv('_APP_DB_ADAPTER', 'mongodb');
$this->lockedDatabase = $database;
parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, $database);
}
protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
{
parent::startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, true, $this->lockedDatabase);
}
}
@@ -0,0 +1,52 @@
// @ts-check
const { test, expect } = require('@playwright/test');
test.describe('Appwrite Web Installer - Screenshots', () => {
test('capture screenshots of wizard steps', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await expect(page.locator('h1')).toContainText('Appwrite Installation');
await page.screenshot({ path: 'tests/playwright/screenshots/step-1-configuration.png', fullPage: true });
await page.locator('#httpPort').fill('8080');
await page.locator('#httpsPort').fill('8443');
await page.locator('#primaryButton').click();
await page.waitForTimeout(300);
await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/);
await page.screenshot({ path: 'tests/playwright/screenshots/step-2-database.png', fullPage: true });
await page.locator('#primaryButton').click();
await page.waitForTimeout(300);
await page.screenshot({ path: 'tests/playwright/screenshots/step-3-domain.png', fullPage: true });
await page.locator('#appDomain').fill('appwrite.example.com');
await page.locator('#emailCertificates').fill('admin@example.com');
await page.locator('#primaryButton').click();
await page.waitForTimeout(300);
await page.screenshot({ path: 'tests/playwright/screenshots/step-4-security.png', fullPage: true });
await page.locator('button:has-text("Review")').click();
await page.waitForTimeout(300);
await page.screenshot({ path: 'tests/playwright/screenshots/step-5-review.png', fullPage: true });
await page.locator('#reviewContent').screenshot({ path: 'tests/playwright/screenshots/step-5-review-detail.png' });
await page.setViewportSize({ width: 375, height: 812 });
await page.waitForTimeout(300);
await page.screenshot({ path: 'tests/playwright/screenshots/mobile-view.png', fullPage: true });
});
test('capture validation states', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.locator('#httpPort').fill('invalid');
await page.locator('#primaryButton').click();
await page.waitForTimeout(300);
await expect(page.locator('.alert.is-error')).toBeVisible();
await page.screenshot({ path: 'tests/playwright/screenshots/validation-error.png', fullPage: true });
});
});
+132
View File
@@ -0,0 +1,132 @@
// @ts-check
const { test, expect } = require('@playwright/test');
test.describe('Appwrite Web Installer', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('loads installer wizard shell', async ({ page }) => {
await expect(page).toHaveTitle(/Appwrite Installation/);
await expect(page.locator('h1')).toContainText('Appwrite Installation');
await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 1/i);
});
test('shows configuration fields by default', async ({ page }) => {
await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/);
await expect(page.locator('#httpPort')).toHaveValue('80');
await expect(page.locator('#httpsPort')).toHaveValue('443');
});
test('validates port numbers on step 1', async ({ page }) => {
await page.locator('#httpPort').fill('invalid');
await page.locator('#primaryButton').click();
await expect(page.locator('.alert.is-error')).toBeVisible();
await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/);
});
test('advances to database step with valid ports', async ({ page }) => {
await page.locator('#httpPort').fill('8080');
await page.locator('#httpsPort').fill('8443');
await page.locator('#primaryButton').click();
await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/);
await expect(page.locator('.database-option')).toHaveCount(2);
await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 2/i);
});
test('moves to domain step after database selection', async ({ page }) => {
await page.locator('#primaryButton').click();
await page.locator('#primaryButton').click();
await expect(page.locator('.step[data-step="3"]')).toHaveClass(/active/);
await expect(page.locator('#appDomain')).toBeVisible();
await expect(page.locator('#emailCertificates')).toBeVisible();
await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 3/i);
});
test('validates domain fields', async ({ page }) => {
await page.locator('#primaryButton').click();
await page.locator('#primaryButton').click();
await page.locator('#appDomain').fill('');
await page.locator('#primaryButton').click();
await expect(page.locator('.alert.is-error')).toContainText(/hostname/i);
await page.locator('#appDomain').fill('appwrite.example.com');
await page.locator('#emailCertificates').fill('invalid');
await page.locator('#primaryButton').click();
await expect(page.locator('.alert.is-error')).toContainText(/valid email/i);
});
test('moves to security step with valid domain data', async ({ page }) => {
await page.locator('#primaryButton').click();
await page.locator('#primaryButton').click();
await page.locator('#appDomain').fill('appwrite.example.com');
await page.locator('#emailCertificates').fill('admin@example.com');
await page.locator('#primaryButton').click();
await expect(page.locator('.step[data-step="4"]')).toHaveClass(/active/);
const keyValue = await page.locator('#opensslKey').inputValue();
expect(keyValue.length).toBeGreaterThan(32);
await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 4/i);
});
test('shows review step before installation', async ({ page }) => {
await page.locator('#primaryButton').click();
await page.locator('#primaryButton').click();
await page.locator('#appDomain').fill('appwrite.example.com');
await page.locator('#emailCertificates').fill('admin@example.com');
await page.locator('#primaryButton').click();
await page.locator('button:has-text("Review")').click();
await expect(page.locator('.step[data-step="5"]')).toHaveClass(/active/);
await expect(page.locator('#reviewContent')).toContainText('appwrite.example.com');
await expect(page.locator('#reviewContent')).toContainText('admin@example.com');
await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 5/i);
});
test('allows navigating backwards', async ({ page }) => {
await page.locator('#primaryButton').click();
await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/);
await page.locator('#backButton').click();
await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/);
});
test('prepares primary action before installation', async ({ page }) => {
await page.locator('#httpPort').fill('8080');
await page.locator('#httpsPort').fill('8443');
await page.locator('#primaryButton').click();
await page.locator('#primaryButton').click();
await page.locator('#appDomain').fill('appwrite.local');
await page.locator('#emailCertificates').fill('admin@appwrite.local');
await page.locator('#primaryButton').click();
await page.locator('button:has-text("Review")').click();
await expect(page.locator('#reviewContent')).toContainText('8080');
await expect(page.locator('#reviewContent')).toContainText('appwrite.local');
await expect(page.locator('button:has-text("Install Appwrite")')).toBeVisible();
});
test('keeps installation log hidden until triggered', async ({ page }) => {
await page.locator('#primaryButton').click();
await page.locator('#primaryButton').click();
await page.locator('#appDomain').fill('appwrite.test');
await page.locator('#emailCertificates').fill('test@appwrite.test');
await page.locator('#primaryButton').click();
await page.locator('button:has-text("Review")').click();
await expect(page.locator('#installationLog')).toBeHidden();
});
test('loads Pink design tokens', async ({ page }) => {
const stylesheets = await page.evaluate(() => Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(link => link.getAttribute('href')));
expect(stylesheets.some(href => href && href.includes('pink'))).toBe(true);
});
});
test.describe('Installer API', () => {
test.skip('API tests require the install task to be running', () => {
// Intentionally skipped pending API harness for installer.
});
});