Compare commits

...
Author SHA1 Message Date
ArnabChatterjee20k 5405ecab36 updated db 2026-03-18 20:28:18 +05:30
ArnabChatterjee20k f02a36f998 revert db 2026-03-18 19:49:00 +05:30
HemachandarandGitHub 6b573524e7 fix: Ensure rules are deleted properly (#11575)
* fix: Ensure rules are deleted properly

* tests
2026-03-18 19:02:03 +05:30
Matej BačoandGitHub 12d274a72c Merge pull request #11566 from appwrite/feat-platform-endpoints
Feat: Public project APIs
2026-03-18 11:57:18 +01:00
Eldad A. FuxandGitHub 6c383b41f5 Merge pull request #11573 from appwrite/fix-realtime-errors
Fix realtime errors
2026-03-17 22:48:30 +01:00
eldadfux b2cb9397fd better realtime errors
Made-with: Cursor
2026-03-17 20:54:47 +01:00
Matej Bačo 6d8bf6c0dd AI review fixes 2026-03-17 13:56:27 +01:00
Matej Bačo bf375d1d40 Fix bugs 2026-03-17 13:52:50 +01:00
Matej Bačo fd05827089 Implement tests 2026-03-17 13:27:53 +01:00
Matej Bačo ac2b35004d Add more public webhook endpoints 2026-03-17 13:03:26 +01:00
Luke B. SilverandGitHub c4b7bb8ee9 Merge pull request #11568 from appwrite/ci/phpstan-baseline-2
ci: reduce PHPStan level to 3 and regenerate baseline
2026-03-17 11:25:58 +00:00
loks0nandClaude Sonnet 4.6 48853cdbe1 ci: reduce PHPStan level to 3 and regenerate baseline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:16:59 +00:00
Matej Bačo 6b6092c9ec Add more webhook endpoints 2026-03-17 11:20:18 +01:00
Matej Bačo 677bb048cc Introduce new webhooks API 2026-03-17 11:03:18 +01:00
Luke B. SilverandGitHub b093a3fb24 Merge pull request #11563 from appwrite/test/flaky-usage
fix: use console headers for attribute status polling in usage tests
2026-03-16 18:47:55 +00:00
loks0nandClaude Sonnet 4.6 3dc9a10fab fix: use console headers for execution status polling in usage tests
Switch function execution status polling in testPrepareFunctionsStats to
use console headers so the non-deterministic number of poll requests does
not skew the project request count assertion or cause null-status failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 17:41:25 +00:00
Luke B. SilverandGitHub 2142905409 Merge branch '1.8.x' into test/flaky-usage 2026-03-16 17:28:27 +00:00
loks0nandClaude Sonnet 4.6 af401eb950 fix: use console headers for attribute status polling in usage tests
Switch attribute/column status polling in testPrepareDatabaseStatsCollectionsAPI
and testPrepareDatabaseStatsTablesAPI to use console headers so the non-deterministic
number of poll requests does not skew the project request count assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 17:27:41 +00:00
Luke B. SilverandGitHub 392926bc1a Merge pull request #11247 from appwrite/feat-installer
Add web installer
2026-03-16 14:54:09 +00:00
Jake BarnbyandClaude Opus 4.6 06c5a2845f fix: restore deployment polling timeout in Realtime testChannelExecutions
The timeout parameters were accidentally removed in daaf1d7f77, causing
the assertEventually to use the default timeout which is too short for
deployment builds in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:30:55 +13:00
Jake BarnbyandClaude Opus 4.6 e6272f7a35 fix: update utopia-php/database to fix shared tables collection recreation
Points to fix-collection-recreate branch which prevents the orphan
reconciliation logic from dropping and recreating shared physical tables
when a new tenant creates collections. In shared-tables mode, a
DuplicateException from the adapter means the table is shared with other
tenants, not an orphan from a partial failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:58:44 +13:00
Jake BarnbyandClaude Opus 4.6 e8790c405b fix: sync composer.lock content-hash with composer.json
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:10:15 +13:00
Jake Barnby 83a8e2811d Update DB 2026-03-17 00:08:01 +13:00
Jake BarnbyandClaude Opus 4.6 41017bb0af fix: revert app/sdks exclusion (not present in CI)
The app/sdks directory is gitignored and doesn't exist in CI. Removing
the exclusion to avoid PHPStan failing on a non-existent path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 23:48:22 +13:00
Jake BarnbyandClaude Opus 4.6 5478d3d601 fix: regenerate PHPStan baseline and exclude SDK tests
The baseline had gone stale after System::getEnv signature changed from
string|null to string. Regenerated to remove unmatched patterns and add
new installer code entries. Also excluded app/sdks from analysis since
those are auto-generated SDK files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 23:30:19 +13:00
Jake Barnby 176a29d608 Remove redundant dep 2026-03-16 21:42:37 +13:00
Jake Barnby ee732581d8 Update DB 2026-03-16 21:40:06 +13:00
Jake Barnby a94649fb58 Merge remote-tracking branch 'origin/1.8.x' into feat-installer
# Conflicts:
#	composer.json
#	composer.lock
2026-03-16 21:25:10 +13:00
Chirag AggarwalandGitHub 366da19b61 Merge pull request #11472 from appwrite/update-python-sdk-readme
chore: update python sdk readme with pydantic models
2026-03-16 13:40:36 +05:30
Luke B. SilverandGitHub 5a56a2fc35 Merge pull request #11551 from appwrite/ci/better-checks
ci: migrate checks to use setup-php and setup-node
2026-03-15 18:59:35 +00:00
loks0nandClaude Sonnet 4.6 5e3f698b57 chore: remove HeadersBench entries from phpstan baseline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:51:07 +00:00
loks0nandClaude Sonnet 4.6 a86217bf4b chore: remove phpbench dependency and benchmark files
phpbench pulled in doctrine/annotations which has a security advisory.
The bench script and its only benchmark file are unused in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:48:24 +00:00
loks0nandClaude Sonnet 4.6 4863b29754 ci: install dependencies before composer audit
composer audit requires packages to be installed to check them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:43:51 +00:00
loks0nandClaude Sonnet 4.6 e0a2b5c1ef ci: unset COMPOSER_NO_AUDIT for audit step
setup-php sets COMPOSER_NO_AUDIT=1 by default, which causes composer
audit to skip. Override it to 0 for the audit step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:42:36 +00:00
loks0nandClaude Sonnet 4.6 bc2a0d573c ci: resolve merge conflict with 1.8.x
Use composer analyze (renamed from composer check in 1.8.x).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:40:38 +00:00
loks0nandClaude Sonnet 4.6 e1bb4fc2cc ci: migrate checks to use setup-php and setup-node instead of Docker
Replace docker-based composer/node invocations in the format and analyze
jobs with shivammathur/setup-php@v2 and actions/setup-node@v4. Split
locale check and composer validate/audit into dedicated jobs for clarity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:38:50 +00:00
Luke B. SilverandGitHub 79f907ff55 Merge pull request #11550 from appwrite/ci/phpstan-baseline
ci: upgrade PHPStan to v2 with baseline and full codebase coverage
2026-03-15 18:12:52 +00:00
loks0nandClaude Sonnet 4.6 937d94972f ci: upgrade PHPStan to v2 with baseline and full codebase coverage
- Upgrade phpstan/phpstan from 1.12.* to ^2.0
- Raise analysis level to max
- Expand paths from 3 specific dirs to src/, app/, bin/, tests/
- Generate baseline capturing 95,365 existing errors for incremental adoption
- Rename composer script from `check` to `analyze`
- Add --memory-limit=1G to handle larger scan scope

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 17:49:49 +00:00
Luke B. SilverandGitHub aefa51f261 Merge pull request #11539 from appwrite/chore-speedup-tests
Chore: Speed-up deployments in tests
2026-03-15 17:16:26 +00:00
loks0nandClaude Sonnet 4.6 f9ecd28dd0 Revert composer.lock to 1.8.x
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:57:39 +00:00
Matej Bačoandloks0n 7e6db9a99b Fix tests missing lock files 2026-03-15 16:39:12 +00:00
Matej Bačoandloks0n c618372d37 linter fix 2026-03-15 16:39:12 +00:00
Matej Bačoandloks0n 975a38bf43 Speed-up deployments in tests 2026-03-15 16:39:12 +00:00
Luke B. SilverandGitHub 6b21968413 Merge pull request #11549 from appwrite/test/split-databases
Split TablesDB e2e tests into a separate CI job
2026-03-15 16:36:14 +00:00
loks0nandClaude Opus 4.6 286e0e5802 Fix import ordering in TablesDB test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:25:05 +00:00
loks0nandClaude Opus 4.6 ac05c9e9ce Split TablesDB e2e tests into a separate CI job
Move all TablesDB* test files from tests/e2e/Services/Databases/ to a
new tests/e2e/Services/TablesDB/ directory, updating namespaces and
adding explicit imports for shared base traits. Add TablesDB as a
separate service in the CI matrix so /v1/databases and /v1/tables tests
run as independent parallel jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:22:01 +00:00
Luke B. SilverandGitHub 153e660274 Merge pull request #11543 from appwrite/fix/ci-before-main-2
Fix devalue vulnerability in astro test sites
2026-03-14 20:13:46 +00:00
loks0nandClaude Opus 4.6 ad97feaf6e Optimize CI matrix job to fetch composer.lock directly
Replace paginated listFiles API call with targeted getContent calls
to avoid timeouts on large PRs with thousands of changed files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 19:25:40 +00:00
loks0nandClaude Opus 4.6 ef618b831d Fix devalue vulnerability by updating from 5.6.3 to 5.6.4
Resolves GHSA-cfw5-2vxh-hr84 (CVSS 6.3) and GHSA-mwv9-gp5h-frr4
(CVSS 2.7) in astro test resource sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 19:12:10 +00:00
Jake BarnbyandClaude Opus 4.6 daaf1d7f77 fix: revert test polling timeouts to original values
The root cause (PostgreSQL duplicate index race condition) has been
fixed in the database library, so the timeout workarounds are no
longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:00:36 +13:00
Jake BarnbyandClaude Opus 4.6 1209a2e344 fix: increase Webhooks test polling timeouts to 240s for CI stability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:27:15 +13:00
Jake BarnbyandClaude Opus 4.6 04bbc30e7e fix: move attribute setup outside Swoole coroutine to prevent fatal errors
The assertEventually call inside Coroutine\run() causes a fatal PHP
error (exit 255) when it times out, killing the entire test process.
Moving DB/collection/attribute setup and polling outside the coroutine
ensures PHPUnit can properly handle assertion failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:55:14 +13:00
Jake BarnbyandClaude Opus 4.6 48c9ecb53f fix: increase all Realtime test polling timeouts to 240s for CI stability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:12:48 +13:00
Jake BarnbyandClaude Opus 4.6 eb082cdd86 fix: increase GraphQL Legacy attribute polling timeout to 240s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:03:01 +13:00
Jake BarnbyandClaude Opus 4.6 0c7a9290bd fix: increase Sites deployment timeouts from 120s to 200s for CI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:48:57 +13:00
Jake BarnbyandClaude Opus 4.6 341aabb942 fix: handle PostgreSQL race condition in shared mode project creation
PostgreSQL adapter throws non-Duplicate exceptions when a table/index
already exists during concurrent createCollection. Catch Throwable and
attempt metadata-only creation as fallback. Also increase Realtime
coroutine attribute polling timeout from 30s to 60s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:29:53 +13:00
Jake BarnbyandClaude Opus 4.6 09c963abda fix: use $sequence for team lookup instead of $internalId
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:51:18 +13:00
Jake BarnbyandClaude Opus 4.6 eb2f0b1cea fix: restore team lookup by $internalId and remove duplicate composer key
The team resource query was changed from $internalId to $sequence,
breaking console session authentication (401 on project creation).
Also removed duplicate utopia-php/servers entry in composer.json
and updated composer.lock hash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:40:29 +13:00
Jake BarnbyandGitHub 01a9340eaf Merge branch '1.8.x' into feat-installer 2026-03-14 10:30:29 +13:00
Luke B. SilverandGitHub 021c5fcad6 Merge pull request #11449 from appwrite/refactor/usage-publishing
Refactor usage metrics to stateless publisher pattern
2026-03-13 16:36:04 +00:00
Luke B. SilverandGitHub 4309bd075e Merge pull request #11538 from appwrite/chore/pin-servers
Pin utopia-php/servers to 0.2.5
2026-03-13 14:00:43 +00:00
loks0nandClaude Opus 4.6 236dceae2c Pin utopia-php/servers to 0.2.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:59:06 +00:00
Jake BarnbyandClaude Opus 4.6 8eb309e92c fix: update utopia-php/database to 5.3.12 for tenant type comparison fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:17:07 +13:00
Jake Barnby 6fee0cb73e Revert "fix: cast tenant to int for SQL adapters to fix strict type comparison"
This reverts commit 840b1fbef3.
2026-03-14 01:16:14 +13:00
Jake BarnbyandClaude Opus 4.6 840b1fbef3 fix: cast tenant to int for SQL adapters to fix strict type comparison
Document::getTenant() casts numeric tenants to (int), but
adapter->getTenant() held a string from getSequence(). The strict
!== comparison in Database::getCollection() then failed, returning
"Collection not found" for all shared-table MariaDB projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:06:43 +13:00
Matej BačoandGitHub 884b8f10bb Merge pull request #11536 from appwrite/fix-staging-project-creation
Fix: staging env
2026-03-13 12:57:36 +01:00
Jake BarnbyandClaude Opus 4.6 03ebc4857a fix: pin utopia-php/di and servers to prevent breaking upgrades
The composer update pulled in utopia-php/di 0.3.1 which removed the
Dependency class, causing fatal errors. Pin to 0.1.0 and servers 0.2.5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:49:31 +13:00
Jake BarnbyandClaude Opus 4.6 1db4c40c6a fix: update composer.lock to clean database 5.3.11
Picks up reverted Sequence validator changes that were breaking
MariaDB shared tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:37:23 +13:00
Jake BarnbyandClaude Opus 4.6 99f08b9f95 fix: update utopia-php/database to 5.3.11 and utopia-php/mongo to 1.*
Use released versions instead of dev branches:
- database 5.3.11: $tenant type VAR_INTEGER -> VAR_ID for MongoDB compatibility
- mongo 1.0.1: collection exists exception uses error code 48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:17:34 +13:00
Matej Bačo 530d681c48 Fix staging env 2026-03-13 11:25:17 +01:00
Jake BarnbyandClaude Opus 4.6 cdbb8ff7e4 fix: use mongo library branch with collection exists error code fix
Adds utopia-php/mongo dev-fix-collection-exists-code which sets error
code 48 (NamespaceExists) when createCollection detects the collection
already exists. This allows the database adapter to handle it as a
DuplicateException and return true, fixing shared tables mode where
collections like _audit are shared across projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:20:06 +13:00
Jake BarnbyandClaude Opus 4.6 99f21b8af6 fix: use database library fix-mongo-perms branch for VAR_ID tenant type
Updates utopia-php/database to dev-fix-mongo-perms which changes the
$tenant internal attribute type from VAR_INTEGER to VAR_ID, fixing
MongoDB shared tables where tenant values are UUID7 strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:04:42 +13:00
Jake BarnbyandClaude Opus 4.6 871795efa9 fix: remove conditional tenant int cast, use string tenant directly
The $tenant attribute type in the database library is being changed from
VAR_INTEGER to VAR_ID, which handles both SQL (integer) and MongoDB
(UUID7 string) adapters natively. This removes the now-unnecessary
conditional casting pattern throughout the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:53:42 +13:00
Jake Barnby dba8c28741 (fix): resolve permissions test collisions in parallel execution 2026-03-13 19:13:17 +13:00
Jake Barnby 526aa590e1 (fix): add _APP_DATABASE_SHARED_TABLES env to scheduler-executions container 2026-03-13 19:13:13 +13:00
Jake Barnby d91e3543c8 (fix): use adapter ID type to determine tenant value for MongoDB compatibility 2026-03-13 19:13:07 +13:00
Jake BarnbyandClaude Opus 4.6 f15f63c0c1 fix: update utopia-php/database to 5.3.9 for project permission fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:31:54 +13:00
Jake BarnbyandClaude Opus 4.6 fa406adab9 fix: update composer.lock to resolve paratest dependency conflict
symfony/console and symfony/string needed upgrading to satisfy
brianium/paratest v7.19.2's version constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:49:34 +13:00
Jake Barnby aa1012ffb6 Merge remote-tracking branch 'origin/1.8.x' into feat-installer
# Conflicts:
#	.github/workflows/tests.yml
#	composer.lock
2026-03-13 14:47:30 +13:00
Jake Barnby c192a154f5 (fix): Prevent auto-redirect on installer step errors 2026-03-13 14:36:45 +13:00
loks0nandClaude Opus 4.6 0295e27b5d Fix build usage metrics to use actual memory/cpus and fix escapeshellarg double-quoting
Use $memory (which includes minMemory floor) and $cpus instead of raw
spec values in MB-seconds metrics, fixing underreporting for sites and
frameworks bumped to the minimum memory. Also remove redundant double
quotes around escapeshellarg() calls in mv command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:42:41 +00:00
loks0nandClaude Opus 4.6 a804cba999 Refactor usage metrics to stateless publisher pattern
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:36:16 +00:00
Luke B. SilverandGitHub d8f733787f Merge pull request #11531 from appwrite/ci/single-pr-workflow
Consolidate PR workflows into single CI workflow
2026-03-12 14:17:23 +00:00
loks0nandClaude Opus 4.6 1abbca9318 Split SARIF uploads with unique categories to fix codeql-action error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:42:28 +00:00
loks0nandClaude Opus 4.6 e99f682cd6 Update trivy-action to v0.35.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:27:38 +00:00
loks0nandClaude Opus 4.6 26326d05e9 Guard SARIF upload against missing files from failed Trivy scans
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:04:33 +00:00
loks0nandClaude Opus 4.6 e67ed2660a Add actions: read permission for osv-scanner reusable workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:00:44 +00:00
loks0nandClaude Opus 4.6 8d0a4d7f92 Consolidate remaining PR workflows and simplify Trivy scan
- Move check-dependencies into ci.yml as Checks / Dependencies
  (upgrade to osv-scanner-reusable-pr.yml@v2.3.3, drop merge_group)
- Move pr-scan into ci.yml as Checks / Image
  (upgrade Trivy to 0.33.1, use SARIF + upload-sarif instead of
  custom PR comment logic)
- Rename Setup job to Build
- Fix format job git checkout HEAD^2 to only run on pull_request
- Rename PHPStan step correctly (was mislabeled CodeQL)
- Add Docker Hub login to benchmark job
- Remove no-op pull_request trigger from ai-moderator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:56:41 +00:00
loks0nandClaude Opus 4.6 aecca2f503 Consolidate PR workflows into single CI workflow
Merge linter, static-analysis, tests, and benchmark workflows into ci.yml
with structured job naming (Checks / Format, Tests / E2E / ..., etc.).
Shared Docker image build between tests and benchmark. Update actions to
latest versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:39:14 +00:00
Luke B. SilverandGitHub 7e4ec9eae0 Merge pull request #11529 from appwrite/ci/focused-testing
ci: focused testing
2026-03-12 11:38:27 +00:00
Jake Barnby 6319d961eb (refactor): Simplify installer progress completion flow 2026-03-13 00:32:42 +13:00
Jake Barnby 5260577872 (fix): Make installer idempotent and fix session secret encoding 2026-03-13 00:32:40 +13:00
Jake Barnby 254c670c1d (feat): Add service healthchecks and health-based depends_on conditions 2026-03-13 00:32:37 +13:00
loks0nandClaude Opus 4.6 edd948557e Refactor matrix job to use GitHub API and clean up test config
Replace shell-based database change detection with github-script using
the GitHub API, eliminating the need for a full checkout. Restructure
matrix generation with guard clauses and no mutation. Remove ciIgnore
exclude group from test command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:01:36 +00:00
loks0nandClaude Opus 4.6 09317f290a Clean up database env setup and improve matrix naming
Hardcode lowercase env vars per database branch instead of using tr.
Use proper casing for database matrix values (MongoDB, MariaDB, PostgreSQL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:56:47 +00:00
loks0nandClaude Opus 4.6 e5f0c2df12 Consolidate CI test matrix with dynamic database and mode dimensions
Merge 6 E2E jobs into 3 by combining dedicated/shared mode variants
into a single matrix dimension. Database adapters and table modes
expand dynamically based on whether utopia-php/database changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:56:47 +00:00
Eldad A. FuxandGitHub 2afec80a25 Merge pull request #11530 from appwrite/fix-allow-null-branch
Allow null branch
2026-03-12 09:29:25 +01:00
eldadfux 20c65bac37 Allow null branch 2026-03-12 09:02:16 +01:00
Luke B. SilverandGitHub 2c6f37ecad Merge pull request #11526 from appwrite/perf/test-image-pulls
perf: various ci fixes and optimisations
2026-03-12 00:22:06 +00:00
loks0nandClaude Opus 4.6 f09b9a994e Revert .env defaults back to MongoDB
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:59:31 +00:00
loks0nandClaude Opus 4.6 9f4ba3a4a2 Reduce php-retry wait time from 300s to 60s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:47:03 +00:00
loks0nandClaude Opus 4.6 8cb36835cc Restore php-retry action for flaky test resilience
Re-add itznotabug/php-retry@v3 wrapping all test steps with
max_attempts: 2 and retry_wait_seconds: 300. Also restore
pull-requests: write permission needed by the action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:41:26 +00:00
loks0nandClaude Opus 4.6 89419344c2 Restore MariaDB defaults, fix remaining timeouts, filter listener span logs
- Change .env defaults back from MongoDB to MariaDB
- Bump all remaining "Load and Start Appwrite" timeouts from 3 to 5 minutes
- Filter listener.* span logs to only export on error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:20:51 +00:00
loks0nandClaude Opus 4.6 7dfe44cb36 Run abuse-enabled tests across entire test suite, not just Projects
The abuseEnabled jobs previously only ran tests in Services/Projects,
missing the Account abuse test and any future abuseEnabled tests in
other services. Also rename jobs to "Abuse" for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:44:42 +00:00
loks0nandClaude Opus 4.6 4cbe50193a Increase Load and Start Appwrite timeout to 5 minutes
The docker compose pull step alone can take over 2.5 minutes on CI
(e.g. openruntimes-executor, traefik), leaving no time for
docker compose up --wait within 3 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:24:52 +00:00
loks0nandClaude Opus 4.6 16929bc420 Remove remaining --debug flags from test commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:00:00 +00:00
loks0nandClaude Opus 4.6 fd28ad8a66 Remove --debug flag from test commands for quieter CI output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:59:33 +00:00
loks0nandClaude Opus 4.6 38798c8993 Add healthchecks for MariaDB, PostgreSQL, and Redis
Add proper healthchecks to infrastructure services and use
condition: service_healthy for redis in appwrite's depends_on
so it waits for Redis to be ready before starting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:58:30 +00:00
loks0nandClaude Opus 4.6 33ce469ab0 Add Docker healthcheck and use --wait instead of polling loop
Replace the manual shell polling loop (until doctor > /dev/null) with a
proper Docker healthcheck on the appwrite service and `docker compose up
--wait`, which blocks until healthchecks pass. Also reverts the timeout
back to 3 minutes now that image pulls are cached.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:44:15 +00:00
loks0nandClaude Opus 4.6 0bbcc3f570 Move dev tools to docker-compose.override.yml
Move adminer, redis-insight, mongo-express, and graphql-explorer to an
override file. CI sets COMPOSE_FILE=docker-compose.yml explicitly so
these are excluded from test runs, reducing the number of images to
pull from 14 to 10. Local docker compose auto-loads both files so dev
workflow is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:06:44 +00:00
loks0nandClaude Opus 4.6 ecca0d8036 Increase Load and Start Appwrite timeout from 3 to 10 minutes
The 3-minute timeout was too tight with the added docker compose pull
step for downloading third-party images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:48:39 +00:00
loks0nandClaude Opus 4.6 534dc55f17 Remove unnecessary abuse enabled from screenshot tests
The screenshot tests have no abuse-related code. Abuse was only enabled
on these jobs as a side effect of the original sed command applying to
all jobs below e2e_service_test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:35:42 +00:00
loks0nandClaude Opus 4.6 d30df31b82 Move compose-time env vars to docker compose up step
_APP_DATABASE_SHARED_TABLES and _APP_DATABASE_SHARED_TABLES_V1 are
read by the server at boot time via System::getEnv(), not by the test
runner. Passing them via docker compose exec -e had no effect on the
already-running Swoole server. Move them to the Load and Start
Appwrite step so they're set at docker compose up time.

Keep _APP_E2E_RESPONSE_FORMAT on exec since it's read by the test
runner process (tests/e2e/Scopes/Scope.php).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:25:47 +00:00
loks0nandClaude Opus 4.6 dc9a1c03d1 Replace shell exports with GitHub Actions env fields
Use step-level env: fields and GHA expressions for conditional values
instead of shell export statements and if/elif blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:20:23 +00:00
loks0nandClaude Opus 4.6 3613a645d0 Remove php-retry action and run test commands directly
Replace itznotabug/php-retry with native run steps and timeout-minutes.
Also remove pull-requests: write permission that was only needed by
php-retry to post PR comments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:19:04 +00:00
loks0nandClaude Opus 4.6 6aac7ea6ad Update GitHub Actions to latest versions
- actions/cache: v4 → v5
- docker/setup-buildx-action: v3 → v4

Fixes Node.js 20 deprecation warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:17:03 +00:00
loks0nandClaude Opus 4.6 d22642590f Add build directive to all appwrite-dev services for --ignore-buildable
Add x-build YAML anchor and apply it to all services using the
appwrite-dev image so docker compose pull --ignore-buildable correctly
skips them instead of trying to pull from Docker Hub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:11:59 +00:00
loks0nandClaude Opus 4.6 0b416e4475 Skip locally-built images during docker compose pull
Add --ignore-buildable flag so docker compose pull skips images with
build directives (appwrite-dev) instead of trying to pull them from
Docker Hub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:01:25 +00:00
loks0nandClaude Opus 4.6 4e1b710503 Fix Docker Hub login credentials to match repo config
Use `vars.DOCKERHUB_USERNAME` and `secrets.DOCKERHUB_TOKEN` to match
the existing publish and release workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:59:23 +00:00
loks0nandClaude Opus 4.6 e557a5fc6e Add Docker Hub login and parallel image pulls to CI
Login to Docker Hub in all test jobs to avoid rate limits, add
`docker compose pull --quiet` to parallelize image downloads before
`docker compose up`, and replace sed-based .env overrides with native
GitHub Actions env fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:41:49 +00:00
HemachandarandGitHub 7d15d7e1f5 Rename time-travel task (#11523)
* Rename time-travel task

* rename
2026-03-11 16:15:50 +05:30
Luke B. SilverandGitHub ab1d88a44a Merge pull request #11496 from appwrite/telemetry/worker-mails
Enable SMTP keep-alive in mail worker
2026-03-11 09:43:07 +00:00
Jake BarnbyandGitHub af54ff83dc Merge pull request #11495 from appwrite/feat-message-migration 2026-03-11 21:28:00 +13:00
premtsd-codeandGitHub 4b1c6fdf83 Merge branch '1.8.x' into feat-message-migration 2026-03-11 07:10:21 +00:00
Jake BarnbyandGitHub e42656d51a Merge pull request #11521 from appwrite/fix-duplicated-empty-phones 2026-03-11 19:49:46 +13:00
Prem Palanisamy a56c2c11d3 Merge remote-tracking branch 'origin/1.8.x' into feat-message-migration 2026-03-11 06:00:26 +00:00
Prem Palanisamy 0ebda89adf update utopia-php/database to released 5.3.8 2026-03-11 05:56:44 +00:00
eldadfux 4bf3c72196 fix: allow users to update phone number to empty without causing duplicate errors 2026-03-11 06:25:08 +01:00
Jake Barnby 2f01965014 Remove redundant images 2026-03-11 17:03:44 +13:00
Jake BarnbyandClaude Opus 4.6 36bd7a4667 (fix): Address code review security and correctness findings
- Remove var_dump debug calls leaking API keys to stdout
- Stop embedding secret keys in HTML data attributes on upgrades
- Strip sensitive fields from sessionStorage install lock
- Quote hostPath in Docker Compose YAML template
- Remove stack traces from client-facing error responses
- Strip sessionSecret and traces from Status endpoint response
- Fix undefined $input variable (should be $userInput) in CLI install
- Add backtick escaping in .env template to prevent shell injection
- Add 2-hour timeout to isInstallationComplete infinite loop
- Escape user-supplied startCommand in shell strings
- Add LOCK_EX to progress file writes
- Fix typo in Upgrade.php error message
- Remove unused variable in V21 response filter
- Remove dead code in applyLockPayload after sessionStorage sanitization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:58:57 +13:00
Damodar LohaniandGitHub c23945ebc6 Merge pull request #11519 from appwrite/copilot/fix-received-at-timestamp
Update usage to use timestamp of event rather than current timestamp
2026-03-11 07:21:42 +05:45
Steven NguyenandGitHub 0984daf37e Merge branch '1.8.x' into copilot/fix-received-at-timestamp 2026-03-10 15:39:22 -07:00
copilot-swe-agent[bot]andstnguyen90 1113bffb78 fix: use message timestamp for receivedAt in StatsUsage worker
Co-authored-by: stnguyen90 <1477010+stnguyen90@users.noreply.github.com>
2026-03-10 22:19:06 +00:00
copilot-swe-agent[bot] 33cafdfc0e Initial plan 2026-03-10 22:17:26 +00:00
Prem Palanisamy 1be142e4be test: use database fix branch for preserveDates datetime format 2026-03-10 12:33:30 +00:00
loks0nandClaude Opus 4.6 f4b8992cde Enable SMTP keep-alive to reuse connections across mail jobs
Reduces job processing time by avoiding repeated TCP connect, TLS
handshake, and SMTP AUTH on every email sent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:19:18 +00:00
Prem Palanisamy ee2e41fa45 feat: add messaging resource migration support 2026-03-10 09:48:45 +00:00
Jake BarnbyandGitHub 94c6f6e07d Merge pull request #11438 from appwrite/realtime-stats 2026-03-10 21:58:57 +13:00
Luke B. SilverandGitHub f23411f549 Merge pull request #11490 from appwrite/fix-empty-comment
Fix for when vcs comment is empty
2026-03-10 08:51:14 +00:00
Luke B. SilverandGitHub 299778cd50 Merge pull request #11489 from appwrite/feat-namespace-fix
Fix SDK namespace call
2026-03-10 08:33:32 +00:00
ArnabChatterjee20kandGitHub b677586925 Merge branch '1.8.x' into realtime-stats 2026-03-10 13:12:04 +05:30
Jake Barnby fda8aee280 (style): Remove extra blank line in ConfigTest 2026-03-10 20:12:38 +13:00
ArnabChatterjee20k 7f4cba276e updated tests 2026-03-10 12:19:46 +05:30
Jake Barnby e2900d4349 Revert package 2026-03-10 19:49:02 +13:00
ArnabChatterjee20k eccc39a466 refactor: remove realtime metrics from project usage endpoints and related tests 2026-03-10 12:15:25 +05:30
Jake Barnby 8b632eb428 (chore): Remove WEB_INSTALLER.md 2026-03-10 19:42:28 +13:00
Jake Barnby d19f14b60f (style): Remove section comment headers from installer tests 2026-03-10 19:42:19 +13:00
Jake Barnby c4c2534f9a (fix): Add CSRF validation to shutdown endpoint and quote .env values 2026-03-10 19:42:09 +13:00
Jake Barnby 91dffddf3b Format 2026-03-10 17:10:39 +13:00
Jake BarnbyandGitHub a10595f501 Merge pull request #11488 from appwrite/feat-project-realtime-check 2026-03-10 15:10:11 +13:00
Jake BarnbyandGitHub 06e3e98cec Merge branch '1.8.x' into feat-installer 2026-03-10 15:07:42 +13:00
Luke B. SilverandGitHub e42812283b Merge pull request #11492 from appwrite/debug/investigate_bucket_2
fix: health storage errors
2026-03-09 23:07:55 +00:00
loks0nandClaude Opus 4.6 4d7aa6d8ab style: format storage health check braces
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:47:07 +00:00
loks0n 95b3db0228 fix: storage health error swallowing 2026-03-09 20:40:34 +00:00
eldadfux 39f3bc7b9d Fix SDK namespace call 2026-03-09 20:08:41 +01:00
eldadfux ab0fa70bca Enhance project context validation in realtime message handling. Ensure that projectId is checked for emptiness before processing messages, and enforce project context requirement for non-ping messages. 2026-03-09 19:31:44 +01:00
Chirag Aggarwal d80aacd706 add type safety 2026-03-07 14:20:36 +05:30
Chirag Aggarwal 4cd1dcc6f3 Merge branch '1.8.x' into update-python-sdk-readme 2026-03-07 12:03:54 +05:30
Chirag Aggarwal 576435c24b chore: update python sdk readme with pydantic models 2026-03-07 09:32:00 +05:30
ArnabChatterjee20k 0ea196d21c updated inbound raw size to the request size 2026-03-06 15:37:30 +05:30
ArnabChatterjee20k ece5b81732 updated test 2026-03-06 15:27:30 +05:30
ArnabChatterjee20kandGitHub 2d92e56795 Merge branch '1.8.x' into realtime-stats 2026-03-06 15:16:26 +05:30
ArnabChatterjee20kandGitHub 12ae61b915 Merge branch '1.8.x' into realtime-stats 2026-03-05 18:40:15 +05:30
ArnabChatterjee20k 03d7bccde2 updated tests 2026-03-05 18:14:16 +05:30
ArnabChatterjee20k b5c2cc9716 Add triggerStats function for realtime usage metrics tracking 2026-03-05 18:10:09 +05:30
ArnabChatterjee20k 9fdd7c1c6e added try catch connection close metric 2026-03-05 11:30:15 +05:30
Jake Barnby 321528cac0 (refactor): Use Composer autoloading, Installer Platform class, and Utopia param validation 2026-03-04 22:58:55 +13:00
Jake Barnby 00d10e62b5 (feat): Add AppDomain validator for installer input 2026-03-04 22:58:48 +13:00
Jake Barnby cd5f3c3eb3 (chore): Bump base Docker image to 1.0.1 2026-03-04 22:58:43 +13:00
ArnabChatterjee20k 82db411517 updated 2026-03-03 19:36:49 +05:30
ArnabChatterjee20k 7644e0fe48 Add realtime metrics for connections, messages, and bandwidth in project usage 2026-03-03 18:48:37 +05:30
Jake BarnbyandClaude Opus 4.6 34dd5006bb fix: update installer ModuleTest for new Shutdown action
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:04:06 +13:00
Jake Barnby 3cc2375b15 Merge remote-tracking branch 'origin/1.8.x' into feat-installer 2026-03-03 20:57:13 +13:00
Jake Barnby 5ee58dcc81 (fix): Fix installer progress step transitions and redirect handling 2026-03-03 19:47:37 +13:00
Jake Barnby 1dfee199c0 (fix): Fix installer health check, network connect, and shutdown flow 2026-03-03 19:47:29 +13:00
Jake Barnby 4fda4d4ac3 Fix host access 2026-03-03 00:46:51 +13:00
Jake Barnby 9c7cef2971 Fix pg 18 path 2026-03-03 00:46:38 +13:00
Jake Barnby 029cd695dd Use custom image 2026-03-03 00:46:25 +13:00
Jake Barnby 349bc63618 Trap ctrl+c on start 2026-03-02 21:53:08 +13:00
Jake Barnby dcfc33c65d (chore): Update lock 2026-03-02 21:50:30 +13:00
Jake Barnby 4c76ddc2a3 (fix): Fix installer Swoole migration and database selection 2026-03-02 21:50:25 +13:00
Jake Barnby f540df50b1 Merge remote-tracking branch 'origin/1.8.x' into feat-installer 2026-03-02 18:40:37 +13:00
Jake Barnby adda327509 Update lock 2026-03-02 18:40:19 +13:00
Jake BarnbyandGitHub 1790f56d99 Merge branch '1.8.x' into feat-installer 2026-02-27 06:07:56 +00:00
Jake BarnbyandClaude Opus 4.6 18d7b12dff fix: capture readExistingCompose result and document shadowed param in Upgrade
- Store readExistingCompose() return value in $data before using it
  (previously $data was undefined on line 60)
- Add comment documenting that CLI database param is intentionally
  overridden by detection from existing configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 02:56:55 +13:00
Jake BarnbyandClaude Opus 4.6 118199fd4b fix: revert name/email hashing in analytics payload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 02:54:06 +13:00
Jake BarnbyandClaude Opus 4.6 7a27cf4ac7 fix: address review feedback for installer PR
- Initialize $isUpgrade=false in Install.php action() to prevent undefined variable
- Assign $this->lockedDatabase in Upgrade.php before calling parent::action()
- Remove stack trace exposure from buildErrorDetails() in Http Install action
- Suppress raw exception messages for 500+ errors in Error handler
- Remove sessionSecret from progress details to prevent credential leak
- Hash name/email in analytics payload to avoid sending raw PII
- Validate and default dbService in compose.phtml to prevent invalid output
- Fix host normalization in progress.js redirect URL builder
- Release global lock on early return for existing installation conflict
- Consolidate duplicate database host/port assignment blocks
- Add @runInSeparateProcess to testRouteRegistration to prevent global state leak

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:44:46 +13:00
Jake Barnby f8d8997ae3 Update installer 2026-02-27 01:29:38 +13:00
Jake Barnby a7046dbe3c (fix): Replace tab characters with spaces in tests.yml workflow 2026-02-26 22:43:23 +13:00
Jake Barnby 5f58e1da90 (fix): Fix syntax error and lint issues in Upgrade.php 2026-02-26 22:13:46 +13:00
Jake Barnby 7f29baec77 (style): Fix indentation and import ordering lint errors 2026-02-26 22:02:49 +13:00
Jake Barnby bc992f15fc (chore): Update composer.lock to match composer.json 2026-02-26 21:56:38 +13:00
Jake Barnby 904f94659f (docs): Update web installer docs for Swoole architecture 2026-02-26 21:42:48 +13:00
Jake Barnby 8892e4f30e (test): Add unit tests for installer module, state, and config 2026-02-26 21:42:42 +13:00
Jake Barnby 41948cb337 (refactor): Replace HttpHandler with Utopia Platform module/action pattern and Swoole HTTP server 2026-02-26 21:42:37 +13:00
Jake Barnby 26dac3a410 Merge remote-tracking branch 'origin/1.8.x' into feat-installer
# Conflicts:
#	.gitignore
#	composer.lock
#	tests/e2e/Services/Databases/DatabasesBase.php
2026-02-26 18:51:23 +13:00
Jake Barnby 3ecb4ee4e2 Sync 1.8.x 2026-02-26 18:50:29 +13:00
Jake Barnby b41678d57a Merge remote-tracking branch 'origin/feat-mongodb' into feat-installer
# Conflicts:
#	.github/workflows/tests.yml
#	Dockerfile
#	app/views/install/compose.phtml
#	composer.lock
#	mongo-entrypoint.sh
#	src/Appwrite/Platform/Tasks/Install.php
#	src/Appwrite/Platform/Tasks/Upgrade.php
#	tests/e2e/Client.php
#	tests/e2e/Services/Databases/DatabasesBase.php
#	tests/e2e/Services/Databases/Legacy/DatabasesCustomClientTest.php
#	tests/e2e/Services/Databases/Legacy/DatabasesCustomServerTest.php
#	tests/e2e/Services/Databases/TablesDB/DatabasesBase.php
#	tests/e2e/Services/Databases/TablesDB/DatabasesCustomClientTest.php
#	tests/e2e/Services/Databases/TablesDB/DatabasesCustomServerTest.php
#	tests/e2e/Services/Databases/Transactions/TransactionsBase.php
#	tests/e2e/Services/GraphQL/Legacy/DatabaseServerTest.php
#	tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
#	tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
#	tests/e2e/Services/Teams/TeamsCustomClientTest.php
2026-02-13 17:09:42 +13:00
Jake Barnby 97be6e2cf4 Merge branch 'feat-installer' of github.com:appwrite/appwrite into feat-installer 2026-02-05 00:31:10 +13:00
Jake Barnby 1df76ed975 Merge remote-tracking branch 'origin/1.8.x' into feat-installer
# Conflicts:
#	Dockerfile
#	app/views/install/compose.phtml
#	composer.lock
#	src/Appwrite/Platform/Tasks/Install.php
2026-02-05 00:29:29 +13:00
Jake BarnbyandGitHub 2278a423f5 Merge pull request #11175 from appwrite/new-install-ui
Add web installer
2026-02-04 11:15:20 +00:00
Darshan 282b3c74ec fix: local install path. 2026-02-04 15:49:05 +05:30
Darshan 180276bd9d remove: mock stuff.
fix: layout regression.
2026-02-04 15:48:37 +05:30
Darshan abab48c1b9 remove: mock stuff. 2026-02-04 14:48:42 +05:30
Darshan 587f9965a2 misc. 2026-02-03 14:37:29 +05:30
Darshan bdad14e83a fix: gradient. 2026-02-03 14:32:24 +05:30
Darshan 92e94e365f update: send to existing analyics endpoint. 2026-02-03 13:54:20 +05:30
Darshan 35918465f8 update: address comments, remove name field and auto-derive. 2026-02-02 19:00:54 +05:30
Darshan 42e52fcaa6 revert: handle fallbacks on growth. 2026-01-30 12:11:39 +05:30
Darshan ba29e66292 add: fallbacks. 2026-01-30 12:09:09 +05:30
Darshan d8cc3202c6 add: tracking on installations. 2026-01-30 11:44:00 +05:30
Darshan 02ce669993 update: design changes on creds step. 2026-01-30 11:08:26 +05:30
Darshan a2a24ee367 update: behavioural hotfix. 2026-01-26 19:27:22 +05:30
Darshan 5e0f163f1f fix: tests. 2026-01-26 19:25:08 +05:30
Darshan c9a6a0f0aa add: deps. 2026-01-26 18:57:50 +05:30
Darshan f5681f1167 fix: bun availability on CI. 2026-01-26 18:44:55 +05:30
Darshan 0e4d514007 update: docs. 2026-01-26 18:41:44 +05:30
Darshan c39d8ad591 fix: tests. 2026-01-26 18:31:16 +05:30
Darshan f59796e290 update: use helpers. 2026-01-26 18:06:36 +05:30
Darshan cff11cae5a update: use more constants. 2026-01-26 18:00:45 +05:30
Darshan aa3047f338 update: use constants for regexes. 2026-01-26 17:48:08 +05:30
Darshan e17b8cec38 update: consistent naming pattern. 2026-01-26 17:40:19 +05:30
Darshan fc2ee0c32a update: inline args. 2026-01-26 17:35:13 +05:30
Darshan 5690eb1dc5 update: filter checks. 2026-01-26 17:26:18 +05:30
Darshan fbba7166ae update: send mock/default values from backend.
update: update task callback.
2026-01-26 17:22:02 +05:30
Darshan 289c3e233e update: misc fixes. 2026-01-26 17:14:31 +05:30
Darshan 6345802b1a update: use helper method. 2026-01-26 17:06:52 +05:30
Darshan 7fb654e3a4 misc: updates, cleanups. 2026-01-26 16:10:51 +05:30
Darshan 8b9051751a update: password validation.
misc: updates, cleanups.
2026-01-26 15:26:56 +05:30
Darshan 335c3f363c fix: ci. 2026-01-25 18:07:42 +05:30
Darshan 6dac2ce253 improve: animations like imagine.
fix: password eye button not showing again.
2026-01-25 18:01:58 +05:30
Darshan f34e40e1e4 add: ci tests. 2026-01-25 17:12:55 +05:30
Darshan fe74760177 update: screenshot tests. 2026-01-25 17:06:10 +05:30
Darshan 2d5e64a21e update: screenshot tests. 2026-01-25 15:42:09 +05:30
Darshan b503965e8c update: tests. 2026-01-25 15:20:09 +05:30
Darshan a9da80687f remove: unneeded check. 2026-01-25 11:33:24 +05:30
Darshan d4dd6cf634 add: completion marker. 2026-01-25 11:33:05 +05:30
Darshan 85d2e08dc5 update: installer doc. 2026-01-25 10:51:09 +05:30
Darshan 0eda9e6315 add: account creation.
feat: mocking options.
2026-01-24 20:09:56 +05:30
Darshan eb8abcfe63 fix: empty input considered as invalid.
fix: referencing wrong file in upgrade task.
2026-01-24 16:35:14 +05:30
Darshan 80a5c24840 bump: gitignore. 2026-01-24 16:32:01 +05:30
Darshan 5144b1ce88 update: splits.
fix: broken SSE.
fix: upgrade flow not working.
feat: csrf tokens for better security.
fix: local testing mode with wrong paths.
feat: csrf validations pre-post hook plus proper permissions handling on files.
feat: js modules for better handling of js operations on web installer frontend ui.
2026-01-24 16:30:51 +05:30
Darshan 3d86f67acb update: move server handling to a new file.
update: tooltip things.
remove: unused var.
2026-01-22 19:52:46 +05:30
Darshan 58440e70ac misc. 2026-01-22 18:12:41 +05:30
Darshan dd16ead898 update: new installer ui. 2026-01-22 17:28:23 +05:30
Jake Barnby bdb8bc081f Add doc 2026-01-21 18:09:46 +13:00
Jake Barnby cbf0cc842d Add web installer 2025-12-02 23:33:21 +13:00
198 changed files with 25811 additions and 5736 deletions
-2
View File
@@ -5,8 +5,6 @@ on:
types: [opened, edited]
issue_comment:
types: [created, edited]
pull_request:
types: [opened, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
-123
View File
@@ -1,123 +0,0 @@
name: Benchmark
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
CACHE_KEY: 'appwrite-dev-${{ github.event.pull_request.head.sha }}'
'on':
- pull_request
jobs:
setup:
name: Setup & Build Appwrite Image
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Appwrite
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: '${{ env.IMAGE }}'
load: true
cache-from: type=gha
cache-to: 'type=gha,mode=max'
outputs: 'type=docker,dest=/tmp/${{ env.IMAGE }}.tar'
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- name: Cache Docker Image
uses: actions/cache@v4
with:
key: '${{ env.CACHE_KEY }}'
path: '/tmp/${{ env.IMAGE }}.tar'
benchmarking:
name: Benchmark
runs-on: ubuntu-latest
needs: setup
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: '${{ env.CACHE_KEY }}'
path: '/tmp/${{ env.IMAGE }}.tar'
fail-on-cache-miss: true
- name: Load and Start Appwrite
run: |
sed -i 's/traefik/localhost/g' .env
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
sleep 10
- name: Install Oha
run: |
echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
sudo apt update
sudo apt install oha
oha --version
- name: Benchmark PR
run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
- name: Cleaning
run: docker compose down -v
- name: Installing latest version
run: |
rm docker-compose.yml
rm .env
curl https://appwrite.io/install/compose -o docker-compose.yml
curl https://appwrite.io/install/env -o .env
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
docker compose up -d
sleep 10
- name: Benchmark Latest
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
- name: Prepare comment
run: |
echo '## :sparkles: Benchmark results' > benchmark.txt
echo ' ' >> benchmark.txt
echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
echo " " >> benchmark.txt
echo " " >> benchmark.txt
echo "## :zap: Benchmark Comparison" >> benchmark.txt
echo " " >> benchmark.txt
echo "| Metric | This PR | Latest version | " >> benchmark.txt
echo "| --- | --- | --- | " >> benchmark.txt
echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
- name: Save results
uses: actions/upload-artifact@v6
if: '${{ !cancelled() }}'
with:
name: benchmark.json
path: benchmark.json
retention-days: 7
- name: Find Comment
if: github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: '${{ github.event.pull_request.number }}'
comment-author: 'github-actions[bot]'
body-includes: Benchmark results
- name: Comment on PR
if: github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: '${{ steps.fc.outputs.comment-id }}'
issue-number: '${{ github.event.pull_request.number }}'
body-path: benchmark.txt
edit-mode: replace
-19
View File
@@ -1,19 +0,0 @@
name: Check dependencies
# Adapted from https://google.github.io/osv-scanner/github-action/#scan-on-pull-request
on:
pull_request:
branches: [main, 1.*.x]
merge_group:
branches: [main, 1.*.x]
permissions:
# Require writing security events to upload SARIF file to security tab
security-events: write
# Only need to read contents
contents: read
jobs:
scan-pr:
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.7.1"
+707
View File
@@ -0,0 +1,707 @@
name: CI
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }}
on:
pull_request:
workflow_dispatch:
inputs:
response_format:
description: 'Response format version to test (e.g., 1.5.0, 1.4.0)'
required: false
type: string
default: ''
jobs:
dependencies:
name: Checks / Dependencies
if: github.event_name == 'pull_request'
permissions:
actions: read
security-events: write
contents: read
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
security:
name: Checks / Image
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Check out code
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: 'recursive'
- name: Build the Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: pr_image:${{ github.sha }}
target: production
- name: Run Trivy vulnerability scanner on image
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: 'pr_image:${{ github.sha }}'
format: 'sarif'
output: 'trivy-image-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Run Trivy vulnerability scanner on source code
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
skip-setup-trivy: true
- name: Upload image scan results
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
- name: Upload source code scan results
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-source'
composer:
name: Checks / Composer
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
coverage: none
- name: Validate
run: composer validate
- name: Install dependencies
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Audit
env:
COMPOSER_NO_AUDIT: 0
run: composer audit
format:
name: Checks / Format
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 2
- run: git checkout HEAD^2
if: github.event_name == 'pull_request'
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Run Linter
run: composer lint
analyze:
name: Checks / Analyze
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Run PHPStan
run: composer analyze
locale:
name: Checks / Locale
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run Locale check
run: node .github/workflows/static-analysis/locale/index.js
matrix:
name: Tests / Matrix
runs-on: ubuntu-latest
outputs:
databases: ${{ steps.generate.outputs.databases }}
modes: ${{ steps.generate.outputs.modes }}
steps:
- name: Generate matrix
id: generate
uses: actions/github-script@v8
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
const defaultDatabases = ['MongoDB'];
const defaultModes = ['dedicated'];
const pr = context.payload.pull_request;
if (!pr) {
core.setOutput('databases', JSON.stringify(allDatabases));
core.setOutput('modes', JSON.stringify(allModes));
return;
}
const getContent = (ref) => github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: 'composer.lock',
ref,
});
const getDbVersion = (lock) => lock.packages?.find(p => p.name === 'utopia-php/database')?.version;
const [{ data: base }, { data: head }] = await Promise.all([
getContent(pr.base.sha),
getContent(pr.head.sha),
]);
const decode = (content) => JSON.parse(Buffer.from(content, 'base64').toString());
const databaseChanged = getDbVersion(decode(base.content)) !== getDbVersion(decode(head.content));
core.setOutput('databases', JSON.stringify(databaseChanged ? allDatabases : defaultDatabases));
core.setOutput('modes', JSON.stringify(databaseChanged ? allModes : defaultModes));
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Appwrite
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: ${{ env.IMAGE }}
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- name: Cache Docker Image
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
unit:
name: Tests / Unit
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Environment Variables
run: docker compose exec -T appwrite vars
- name: Run Unit Tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 60
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/unit
command: >-
docker compose exec
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
appwrite test /usr/src/code/tests/unit
e2e_general:
name: Tests / E2E / General
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run General Tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 60
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/General
command: >-
docker compose exec -T
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
appwrite test /usr/src/code/tests/e2e/General
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Logs ==="
docker compose logs
e2e_service:
name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }}
runs-on: ubuntu-latest
needs: [build, matrix]
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
database: ${{ fromJSON(needs.matrix.outputs.databases) }}
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
service: [
Account,
Avatars,
Console,
Databases,
TablesDB,
Functions,
FunctionsSchedule,
GraphQL,
Health,
Locale,
Projects,
Realtime,
Sites,
Proxy,
Storage,
Tokens,
Teams,
Users,
ProjectWebhooks,
Webhooks,
VCS,
Messaging,
Migrations
]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Set database environment
run: |
if [ "${{ matrix.database }}" = "MariaDB" ]; then
echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV
echo "_APP_DB_PORT=3306" >> $GITHUB_ENV
elif [ "${{ matrix.database }}" = "MongoDB" ]; then
echo "COMPOSE_PROFILES=mongodb" >> $GITHUB_ENV
echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV
echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV
echo "_APP_DB_PORT=27017" >> $GITHUB_ENV
elif [ "${{ matrix.database }}" = "PostgreSQL" ]; then
echo "COMPOSE_PROFILES=postgresql" >> $GITHUB_ENV
echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV
echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV
echo "_APP_DB_PORT=5432" >> $GITHUB_ENV
fi
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
_APP_BROWSER_HOST: http://invalid-browser/v1
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 60
timeout_minutes: 20
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/${{ matrix.service }}
command: |
SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}"
# Services that rely on sequential test method execution (shared static state)
FUNCTIONAL_FLAG="--functional"
case "${{ matrix.service }}" in
Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
esac
docker compose exec -T \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Logs ==="
docker compose logs
e2e_abuse:
name: Tests / E2E / Abuse (${{ matrix.mode }})
runs-on: ubuntu-latest
needs: [build, matrix]
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
_APP_OPTIONS_ABUSE: enabled
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Run tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 60
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e
command: >-
docker compose exec -T
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
appwrite test /usr/src/code/tests/e2e --group=abuseEnabled
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Logs ==="
docker compose logs
e2e_screenshots:
name: Tests / E2E / Screenshots (${{ matrix.mode }})
runs-on: ubuntu-latest
needs: [build, matrix]
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 60
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/Sites
command: >-
docker compose exec -T
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
appwrite test /usr/src/code/tests/e2e/Services/Sites --group=screenshots
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Logs ==="
docker compose logs
benchmark:
name: Benchmark
runs-on: ubuntu-latest
needs: build
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v5
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
run: |
sed -i 's/traefik/localhost/g' .env
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
sleep 10
- name: Install Oha
run: |
echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
sudo apt update
sudo apt install oha
oha --version
- name: Benchmark PR
run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
- name: Cleaning
run: docker compose down -v
- name: Installing latest version
run: |
rm docker-compose.yml
rm .env
curl https://appwrite.io/install/compose -o docker-compose.yml
curl https://appwrite.io/install/env -o .env
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
docker compose up -d
sleep 10
- name: Benchmark Latest
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
- name: Prepare comment
run: |
echo '## :sparkles: Benchmark results' > benchmark.txt
echo ' ' >> benchmark.txt
echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
echo " " >> benchmark.txt
echo " " >> benchmark.txt
echo "## :zap: Benchmark Comparison" >> benchmark.txt
echo " " >> benchmark.txt
echo "| Metric | This PR | Latest version | " >> benchmark.txt
echo "| --- | --- | --- | " >> benchmark.txt
echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
- name: Save results
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: benchmark.json
path: benchmark.json
retention-days: 7
- name: Find Comment
if: github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: Benchmark results
- name: Comment on PR
if: github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body-path: benchmark.txt
edit-mode: replace
-28
View File
@@ -1,28 +0,0 @@
name: "Linter"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: [pull_request]
jobs:
lint:
name: Linter
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 2
- run: git checkout HEAD^2
- name: Validate composer.json and composer.lock
run: |
docker run --rm -v $PWD:/app composer:2.8 sh -c \
"composer validate"
- name: Run Linter
run: |
docker run --rm -v $PWD:/app composer:2.8 sh -c \
"composer install --profile --ignore-platform-reqs && composer lint"
-106
View File
@@ -1,106 +0,0 @@
name: PR Security Scan
on:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check out code
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
submodules: 'recursive'
- name: Build the Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: pr_image:${{ github.sha }}
target: production
- name: Run Trivy vulnerability scanner on image
uses: aquasecurity/trivy-action@0.20.0
with:
image-ref: 'pr_image:${{ github.sha }}'
format: 'json'
output: 'trivy-image-results.json'
severity: 'CRITICAL,HIGH'
- name: Run Trivy vulnerability scanner on source code
uses: aquasecurity/trivy-action@0.20.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'json'
output: 'trivy-fs-results.json'
severity: 'CRITICAL,HIGH'
- name: Process Trivy scan results
id: process-results
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
let commentBody = '## Security Scan Results for PR\n\n';
function processResults(results, title) {
let sectionBody = `### ${title}\n\n`;
if (results.Results && results.Results.some(result => result.Vulnerabilities && result.Vulnerabilities.length > 0)) {
sectionBody += '| Package | Version | Vulnerability | Severity |\n';
sectionBody += '|---------|---------|----------------|----------|\n';
const uniqueVulns = new Set();
results.Results.forEach(result => {
if (result.Vulnerabilities) {
result.Vulnerabilities.forEach(vuln => {
const vulnKey = `${vuln.PkgName}-${vuln.InstalledVersion}-${vuln.VulnerabilityID}`;
if (!uniqueVulns.has(vulnKey)) {
uniqueVulns.add(vulnKey);
sectionBody += `| ${vuln.PkgName} | ${vuln.InstalledVersion} | [${vuln.VulnerabilityID}](https://nvd.nist.gov/vuln/detail/${vuln.VulnerabilityID}) | ${vuln.Severity} |\n`;
}
});
}
});
} else {
sectionBody += '🎉 No vulnerabilities found!\n';
}
return sectionBody;
}
try {
const imageResults = JSON.parse(fs.readFileSync('trivy-image-results.json', 'utf8'));
const fsResults = JSON.parse(fs.readFileSync('trivy-fs-results.json', 'utf8'));
commentBody += processResults(imageResults, "Docker Image Scan Results");
commentBody += '\n';
commentBody += processResults(fsResults, "Source Code Scan Results");
} catch (error) {
commentBody += `There was an error while running the security scan: ${error.message}\n`;
commentBody += 'Please contact the core team for assistance.';
}
core.setOutput('comment-body', commentBody);
- name: Find Comment
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: Security Scan Results for PR
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: ${{ steps.process-results.outputs.comment-body }}
edit-mode: replace
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
-21
View File
@@ -1,21 +0,0 @@
name: "Static code analysis"
on: [pull_request]
jobs:
lint:
name: CodeQL
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Run CodeQL
run: |
docker run --rm -v $PWD:/app composer:2.8 sh -c \
"composer install --profile --ignore-platform-reqs && composer check"
- name: Run Locale check
run: |
docker run --rm -v $PWD:/app node:24-alpine sh -c \
"cd /app/.github/workflows/static-analysis/locale && node index.js"
-691
View File
@@ -1,691 +0,0 @@
name: "Tests"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }}
on:
pull_request:
workflow_dispatch:
inputs:
response_format:
description: 'Response format version to test (e.g., 1.5.0, 1.4.0)'
required: false
type: string
default: ''
jobs:
check_database_changes:
name: Check if utopia-php/database changed
runs-on: ubuntu-latest
outputs:
database_changed: ${{ steps.check.outputs.database_changed }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Fetch base branch
run: git fetch origin ${{ github.event.pull_request.base.ref }}
- name: Check for utopia-php/database changes
id: check
run: |
if git diff origin/${{ github.event.pull_request.base.ref }} HEAD -- composer.lock | grep -q '"name": "utopia-php/database"'; then
echo "Database version changed, going to run all mode tests."
echo "database_changed=true" >> "$GITHUB_ENV"
echo "database_changed=true" >> "$GITHUB_OUTPUT"
else
echo "database_changed=false" >> "$GITHUB_ENV"
echo "database_changed=false" >> "$GITHUB_OUTPUT"
fi
setup:
name: Setup & Build Appwrite Image
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Appwrite
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: ${{ env.IMAGE }}
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- name: Cache Docker Image
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
unit_test:
name: Unit Test
runs-on: ubuntu-latest
needs: setup
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Environment Variables
run: docker compose exec -T appwrite vars
- name: Run Unit Tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/unit
command: >-
docker compose exec
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
appwrite test /usr/src/code/tests/unit
e2e_general_test:
name: E2E General Test
runs-on: ubuntu-latest
needs: setup
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run General Tests
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/General
command: >-
docker compose exec -T
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
appwrite test /usr/src/code/tests/e2e/General --debug
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Logs ==="
docker compose logs
e2e_service_test:
name: E2E Service Test
runs-on: ubuntu-latest
needs: setup
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
db_adapter: [
MARIADB,
POSTGRESQL,
MONGODB
]
service: [
Account,
Avatars,
Console,
Databases,
Functions,
FunctionsSchedule,
GraphQL,
Health,
Locale,
Projects,
Realtime,
Sites,
Proxy,
Storage,
Tokens,
Teams,
Users,
Webhooks,
VCS,
Messaging,
Migrations
]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Set DB Adapter environment
id: set-db-env
run: |
DB_ADAPTER_LOWER=$(echo "${{ matrix.db_adapter }}" | tr 'A-Z' 'a-z')
echo "COMPOSE_PROFILES=${DB_ADAPTER_LOWER}" >> $GITHUB_ENV
if [ "${{ matrix.db_adapter }}" = "MARIADB" ]; then
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV
echo "_APP_DB_PORT=3306" >> $GITHUB_ENV
elif [ "${{ matrix.db_adapter }}" = "MONGODB" ]; then
echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV
echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV
echo "_APP_DB_PORT=27017" >> $GITHUB_ENV
elif [ "${{ matrix.db_adapter }}" = "POSTGRESQL" ]; then
echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV
echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV
echo "_APP_DB_PORT=5432" >> $GITHUB_ENV
fi
- name: Load and Start Appwrite
timeout-minutes: 3
env:
_APP_BROWSER_HOST: http://invalid-browser/v1
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run ${{ matrix.service }} tests with Project table mode
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 20
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/${{ matrix.service }}
command: |
echo "Using project tables"
SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}"
# Services that rely on sequential test method execution (shared static state)
FUNCTIONAL_FLAG="--functional"
case "${{ matrix.service }}" in
Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
esac
echo "Running with paratest (parallel) for: ${{ matrix.service }} ${FUNCTIONAL_FLAG:+(functional)}"
docker compose exec -T \
-e _APP_DATABASE_SHARED_TABLES="" \
-e _APP_DATABASE_SHARED_TABLES_V1="" \
-e _APP_DB_ADAPTER="${{ env._APP_DB_ADAPTER }}" \
-e _APP_DB_HOST="${{ env._APP_DB_HOST }}" \
-e _APP_DB_PORT="${{ env._APP_DB_PORT }}" \
-e _APP_DB_SCHEMA=appwrite \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Logs ==="
docker compose logs
e2e_shared_mode_test:
name: E2E Shared Mode Service Test
runs-on: ubuntu-latest
needs: [ setup, check_database_changes ]
if: needs.check_database_changes.outputs.database_changed == 'true'
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
service:
[
Account,
Avatars,
Console,
Databases,
Functions,
FunctionsSchedule,
GraphQL,
Health,
Locale,
Projects,
Realtime,
Sites,
Proxy,
Storage,
Teams,
Users,
Webhooks,
VCS,
Messaging,
Migrations,
Tokens
]
tables-mode: [
'Shared V1',
'Shared V2',
]
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run ${{ matrix.service }} tests with ${{ matrix.tables-mode }} table mode
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 20
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/${{ matrix.service }}
command: |
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
echo "Using shared tables V1"
export _APP_DATABASE_SHARED_TABLES=database_db_main
export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
echo "Using shared tables V2"
export _APP_DATABASE_SHARED_TABLES=database_db_main
export _APP_DATABASE_SHARED_TABLES_V1=
fi
SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}"
# Services that rely on sequential test method execution (shared static state)
FUNCTIONAL_FLAG="--functional"
case "${{ matrix.service }}" in
Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
esac
docker compose exec -T \
-e _APP_DATABASE_SHARED_TABLES \
-e _APP_DATABASE_SHARED_TABLES_V1 \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Worker Builds Logs ==="
docker compose logs appwrite-worker-builds
echo "=== OpenRuntimes Executor Logs ==="
docker compose logs openruntimes-executor
e2e_abuse_enabled:
name: E2E Service Test (Abuse enabled)
runs-on: ubuntu-latest
needs: setup
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Run Projects tests in dedicated table mode
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/Projects
command: |
echo "Using project tables"
export _APP_DATABASE_SHARED_TABLES=
export _APP_DATABASE_SHARED_TABLES_V1=
docker compose exec -T \
-e _APP_DATABASE_SHARED_TABLES \
-e _APP_DATABASE_SHARED_TABLES_V1 \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Worker Builds Logs ==="
docker compose logs appwrite-worker-builds
echo "=== OpenRuntimes Executor Logs ==="
docker compose logs openruntimes-executor
e2e_abuse_enabled_shared_mode:
name: E2E Shared Mode Service Test (Abuse enabled)
runs-on: ubuntu-latest
needs: [ setup, check_database_changes ]
if: needs.check_database_changes.outputs.database_changed == 'true'
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
tables-mode: [
'Shared V1',
'Shared V2',
]
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Run Projects tests in ${{ matrix.tables-mode }} table mode
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/Projects
command: |
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
echo "Using shared tables V1"
export _APP_DATABASE_SHARED_TABLES=database_db_main
export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
echo "Using shared tables V2"
export _APP_DATABASE_SHARED_TABLES=database_db_main
export _APP_DATABASE_SHARED_TABLES_V1=
fi
docker compose exec -T \
-e _APP_DATABASE_SHARED_TABLES \
-e _APP_DATABASE_SHARED_TABLES_V1 \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Worker Builds Logs ==="
docker compose logs appwrite-worker-builds
echo "=== OpenRuntimes Executor Logs ==="
docker compose logs openruntimes-executor
e2e_screenshots:
name: E2E Service Test (Site Screenshots)
runs-on: ubuntu-latest
needs: setup
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run Site tests with browser connected in dedicated table mode
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/Sites
command: |
echo "Keeping original value of _APP_BROWSER_HOST"
echo "Using project tables"
export _APP_DATABASE_SHARED_TABLES=
export _APP_DATABASE_SHARED_TABLES_V1=
docker compose exec -T \
-e _APP_DATABASE_SHARED_TABLES \
-e _APP_DATABASE_SHARED_TABLES_V1 \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite test /usr/src/code/tests/e2e/Services/Sites --debug --group=screenshots
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Worker Builds Logs ==="
docker compose logs appwrite-worker-builds
echo "=== OpenRuntimes Executor Logs ==="
docker compose logs openruntimes-executor
e2e_screenshots_shared_mode:
name: E2E Shared Mode Service Test (Site Screenshots)
runs-on: ubuntu-latest
needs: [ setup, check_database_changes ]
if: needs.check_database_changes.outputs.database_changed == 'true'
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
tables-mode: [
'Shared V1',
'Shared V2',
]
steps:
- name: checkout
uses: actions/checkout@v6
- name: Load Cache
uses: actions/cache@v4
with:
key: ${{ env.CACHE_KEY }}
path: /tmp/${{ env.IMAGE }}.tar
fail-on-cache-miss: true
- name: Load and Start Appwrite
timeout-minutes: 3
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
docker compose up -d
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
echo "Waiting for Appwrite to be ready..."
sleep 2
done
- name: Wait for Open Runtimes
timeout-minutes: 3
run: |
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
echo "Waiting for Executor to come online"
sleep 1
done
- name: Run Site tests with browser connected in ${{ matrix.tables-mode }} table mode
uses: itznotabug/php-retry@v3
with:
max_attempts: 2
retry_wait_seconds: 300
timeout_minutes: 15
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/Sites
command: |
echo "Keeping original value of _APP_BROWSER_HOST"
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
echo "Using shared tables V1"
export _APP_DATABASE_SHARED_TABLES=database_db_main
export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
echo "Using shared tables V2"
export _APP_DATABASE_SHARED_TABLES=database_db_main
export _APP_DATABASE_SHARED_TABLES_V1=
fi
docker compose exec -T \
-e _APP_DATABASE_SHARED_TABLES \
-e _APP_DATABASE_SHARED_TABLES_V1 \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite test /usr/src/code/tests/e2e/Services/Sites --debug --group=screenshots
- name: Failure Logs
if: failure()
run: |
echo "=== Appwrite Worker Builds Logs ==="
docker compose logs appwrite-worker-builds
echo "=== OpenRuntimes Executor Logs ==="
docker compose logs openruntimes-executor
+6
View File
@@ -21,3 +21,9 @@ appwrite.config.json
/app/config/specs/
/docs/examples/
.phpunit.cache
playwright-report
test-results
docker-compose.web-installer.yml
.env.web-installer
docker-compose.web-installer.yml.**.backup
tests/playwright/screenshots
+29 -16
View File
@@ -409,14 +409,16 @@ Next follow the appropriate steps below depending on whether you're adding the m
**API**
In file `app/controllers/shared/api.php` On the database listener, add to an existing or create a new switch case. Add a call to the usage worker with your new metric const like so:
In file `app/controllers/shared/api.php` On the database listener, add to an existing or create a new switch case. Accumulate metrics in the usage context like so:
```php
case $document->getCollection() === 'teams':
$queueForStatsUsage
->addMetric(METRIC_TEAMS, $value); // per project
$usage->addMetric(METRIC_TEAMS, $value); // per project
break;
```
The metrics will be automatically published by the shutdown hook at the end of the request. There is no need to manually trigger or publish.
There are cases when you need to handle metric that has a parent entity, like buckets.
Files are linked to a parent bucket, you should verify you remove the files stats when you delete a bucket.
@@ -425,14 +427,13 @@ In that case you need also to handle children removal using addReduce() method c
```php
case $document->getCollection() === 'buckets': //buckets
$queueForStatsUsage
->addMetric(METRIC_BUCKETS, $value); // per project
$usage->addMetric(METRIC_BUCKETS, $value); // per project
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage
$usage
->addReduce($document);
}
break;
```
In addition, you will also need to add some logic to the `reduce()` method of the Usage worker located in `/src/Appwrite/Platform/Workers/Usage.php`, like so:
@@ -460,8 +461,12 @@ case $document->getCollection() === 'buckets':
**Background worker**
You need to inject the usage queue in the desired worker on the constructor method
You need to inject the usage context and publisher in the desired worker on the constructor method
```php
use Appwrite\Usage\Context;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Message\Usage as UsageMessage;
/**
* @throws Exception
*/
@@ -474,24 +479,32 @@ public function __construct()
->inject('dbForProject')
->inject('queueForFunctions')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('usage')
->inject('publisherForUsage')
->inject('log')
->callback(fn (Message $message, Database $dbForProject, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Log $log) => $this->action($message, $dbForProject, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $log));
->callback(fn (Message $message, Database $dbForProject, Func $queueForFunctions, Event $queueForEvents, Context $usage, UsagePublisher $publisherForUsage, Log $log) => $this->action($message, $dbForProject, $queueForFunctions, $queueForEvents, $usage, $publisherForUsage, $log));
}
```
and then trigger the queue with the new metric like so:
and then accumulate metrics, create a message, and publish like so:
```php
$queueForStatsUsage
$usage
->addMetric(METRIC_BUILDS, 1)
->addMetric(METRIC_BUILDS_STORAGE, $build->getAttribute('size', 0))
->addMetric(METRIC_BUILDS_COMPUTE, (int)$build->getAttribute('duration', 0) * 1000)
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS), 1)
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS), 1)
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_STORAGE), $build->getAttribute('size', 0))
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), (int)$build->getAttribute('duration', 0) * 1000)
->setProject($project)
->trigger();
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), (int)$build->getAttribute('duration', 0) * 1000);
// Publish the accumulated metrics (workers don't have shutdown hooks)
$message = new UsageMessage(
project: $project,
metrics: $usage->getMetrics(),
reduce: $usage->getReduce()
);
$publisherForUsage->enqueue($message);
$usage->reset();
```
+3 -2
View File
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
FROM appwrite/base:1.0.0 AS base
FROM appwrite/base:1.0.1 AS base
LABEL maintainer="team@appwrite.io"
@@ -70,7 +70,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/sdks && \
chmod +x /usr/local/bin/specs && \
chmod +x /usr/local/bin/ssl && \
chmod +x /usr/local/bin/time-travel && \
chmod +x /usr/local/bin/task-time-travel && \
chmod +x /usr/local/bin/screenshot && \
chmod +x /usr/local/bin/test && \
chmod +x /usr/local/bin/upgrade && \
@@ -121,5 +121,6 @@ RUN if [ "$DEBUG" = "true" ]; then \
fi
EXPOSE 80
EXPOSE 8080
CMD [ "php", "app/http.php" ]
+21 -16
View File
@@ -4,11 +4,13 @@ require_once __DIR__ . '/init.php';
use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\StatsResources;
use Appwrite\Event\StatsUsage;
use Appwrite\Platform\Appwrite;
use Appwrite\Runtimes\Runtimes;
use Appwrite\Usage\Context as UsageContext;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Swoole\Runtime;
@@ -29,6 +31,7 @@ use Utopia\Platform\Service;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Registry\Registry;
use Utopia\System\System;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
@@ -47,7 +50,7 @@ $platform = new Appwrite();
$args = $platform->getEnv('argv');
\array_shift($args);
if (!isset($args[0])) {
if (! isset($args[0])) {
Console::error('Missing task name');
Console::exit(1);
}
@@ -85,6 +88,7 @@ $setResource('pools', function (Registry $register) {
$setResource('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
}, []);
@@ -113,7 +117,7 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
$collections = Config::getParam('collections', [])['console'];
$last = \array_key_last($collections);
if (!($dbForPlatform->exists($dbForPlatform->getDatabase(), $last))) { /** TODO cache ready variable using registry */
if (! ($dbForPlatform->exists($dbForPlatform->getDatabase(), $last))) { /** TODO cache ready variable using registry */
throw new Exception('Tables not ready yet.');
}
@@ -122,10 +126,10 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
Console::warning($err->getMessage());
sleep($sleep);
}
} while ($attempts < $maxAttempts && !$ready);
} while ($attempts < $maxAttempts && ! $ready);
if (!$ready) {
throw new Exception("Console is not ready yet. Please try again later.");
if (! $ready) {
throw new Exception('Console is not ready yet. Please try again later.');
}
return $dbForPlatform;
@@ -163,7 +167,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int)$project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -184,7 +188,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int)$project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -208,7 +212,7 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant((int)$project->getSequence());
$database->setTenant($project->getSequence());
return $database;
}
@@ -225,7 +229,7 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
// set tenant
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant((int)$project->getSequence());
$database->setTenant($project->getSequence());
}
return $database;
@@ -243,15 +247,16 @@ $setResource('publisherFunctions', function (BrokerPool $publisher) {
$setResource('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('publisherStatsUsage', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('queueForStatsUsage', function (Publisher $publisher) {
return new StatsUsage($publisher);
}, ['publisher']);
$setResource('usage', function () {
return new UsageContext();
}, []);
$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
$setResource('queueForStatsResources', function (Publisher $publisher) {
return new StatsResources($publisher);
}, ['publisher']);
+22
View File
@@ -788,6 +788,17 @@ return [
'default' => null,
'filters' => [],
],
[
'array' => false,
'$id' => ID::custom('specification'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => false,
'required' => false,
'default' => APP_COMPUTE_SPECIFICATION_DEFAULT,
'filters' => [],
],
[
'array' => false,
'$id' => ID::custom('buildSpecification'),
@@ -1245,6 +1256,17 @@ return [
'array' => false,
'filters' => [],
],
[
'array' => false,
'$id' => ID::custom('specification'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => false,
'required' => false,
'default' => APP_COMPUTE_SPECIFICATION_DEFAULT,
'filters' => [],
],
[
'array' => false,
'$id' => ID::custom('buildSpecification'),
+5
View File
@@ -1144,6 +1144,11 @@ return [
'description' => 'Webhook with the requested ID could not be found.',
'code' => 404,
],
Exception::WEBHOOK_ALREADY_EXISTS => [
'name' => Exception::WEBHOOK_ALREADY_EXISTS,
'description' => 'Webhook with the same ID already exists. Try again with a different ID.',
'code' => 409,
],
Exception::KEY_NOT_FOUND => [
'name' => Exception::KEY_NOT_FOUND,
'description' => 'Key with the requested ID could not be found.',
+8
View File
@@ -172,4 +172,12 @@ return [ // List of publicly visible scopes
'tokens.write' => [
'description' => 'Access to create, update, and delete your project\'s tokens',
],
"webhooks.read" => [
"description" =>
"Access to read project\'s webhooks",
],
"webhooks.write" => [
"description" =>
"Access to create, update, and delete project\'s webhooks",
],
];
+9 -17
View File
@@ -14,7 +14,6 @@ use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\Network\Validator\Email as EmailValidator;
@@ -28,6 +27,7 @@ use Appwrite\SDK\MethodType;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\URL\URL as URLParser;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Database\Validator\Queries\Identities;
@@ -2801,12 +2801,12 @@ Http::post('/v1/account/tokens/phone')
->inject('queueForMessaging')
->inject('locale')
->inject('timelimit')
->inject('queueForStatsUsage')
->inject('usage')
->inject('plan')
->inject('store')
->inject('proofForCode')
->inject('authorization')
->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -2955,16 +2955,12 @@ Http::post('/v1/account/tokens/phone')
$countryCode = $helper->parse($phone)->getCountryCode();
if (!empty($countryCode)) {
$queueForStatsUsage
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
$usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
}
} catch (NumberParseException $e) {
// Ignore invalid phone number for country code stats
}
$queueForStatsUsage
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
->setProject($project)
->trigger();
$usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1);
}
$token->setAttribute('secret', $secret);
@@ -4199,11 +4195,11 @@ Http::post('/v1/account/verifications/phone')
->inject('project')
->inject('locale')
->inject('timelimit')
->inject('queueForStatsUsage')
->inject('usage')
->inject('plan')
->inject('proofForCode')
->inject('authorization')
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -4288,16 +4284,12 @@ Http::post('/v1/account/verifications/phone')
$countryCode = $helper->parse($phone)->getCountryCode();
if (!empty($countryCode)) {
$queueForStatsUsage
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
$usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
}
} catch (NumberParseException $e) {
// Ignore invalid phone number for country code stats
}
$queueForStatsUsage
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
->setProject($project)
->trigger();
$usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1);
}
$verification->setAttribute('secret', $secret);
+8 -5
View File
@@ -1560,12 +1560,15 @@ Http::patch('/v1/users/:userId/phone')
$oldPhone = $user->getAttribute('phone');
// Store null instead of empty string so unique constraint allows multiple users without phone
$phoneValue = $number !== '' ? $number : null;
$user
->setAttribute('phone', $number)
->setAttribute('phone', $phoneValue)
->setAttribute('phoneVerification', false)
;
if (\strlen($number) !== 0) {
if ($number !== '') {
$target = $dbForProject->findOne('targets', [
Query::equal('identifier', [$number]),
]);
@@ -1577,7 +1580,7 @@ Http::patch('/v1/users/:userId/phone')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'phone' => $user->getAttribute('phone'),
'phone' => $phoneValue,
'phoneVerification' => $user->getAttribute('phoneVerification'),
]));
/**
@@ -1586,14 +1589,14 @@ Http::patch('/v1/users/:userId/phone')
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
if (\strlen($number) !== 0) {
if ($number !== '') {
$dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number]));
$oldTarget->setAttribute('identifier', $number);
} else {
$dbForProject->deleteDocument('targets', $oldTarget->getId());
}
} else {
if (\strlen($number) !== 0) {
if ($number !== '') {
$target = $dbForProject->createDocument('targets', new Document([
'$permissions' => [
Permission::read(Role::user($user->getId())),
+4 -1
View File
@@ -555,7 +555,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
if (!empty($deployment->getAttribute('startCommand', ''))) {
$startCommand = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', '');
$startCommand = 'cd /usr/local/server/src/function/ && ' . str_replace(['"', '`', '$'], ['\\"', '\\`', '\\$'], $deployment->getAttribute('startCommand', ''));
}
$runtimeEntrypoint = match ($version) {
@@ -1399,6 +1399,9 @@ Http::error()
$sdk = $route?->getLabel("sdk", false);
$action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD';
if (!empty($sdk)) {
if (\is_array($sdk)) {
$sdk = $sdk[0];
}
/** @var \Appwrite\SDK\Method $sdk */
$action = $sdk->getNamespace() . '.' . $sdk->getMethodName();
} elseif ($route === null) {
+104 -90
View File
@@ -10,14 +10,16 @@ use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Message\Usage as UsageMessage;
use Appwrite\Event\Messaging;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\EventProcessor;
use Appwrite\SDK\Method;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -53,7 +55,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
$replace = $parts[1] ?? '';
$params = match ($namespace) {
'user' => (array)$user,
'user' => (array) $user,
'request' => $requestParams,
default => $responsePayload,
};
@@ -61,13 +63,13 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
if (array_key_exists($replace, $params)) {
$replacement = $params[$replace];
// Convert to string if it's not already a string
if (!is_string($replacement)) {
if (! is_string($replacement)) {
if (is_array($replacement)) {
$replacement = json_encode($replacement);
} elseif (is_object($replacement) && method_exists($replacement, '__toString')) {
$replacement = (string)$replacement;
$replacement = (string) $replacement;
} elseif (is_scalar($replacement)) {
$replacement = (string)$replacement;
$replacement = (string) $replacement;
} else {
throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose");
}
@@ -75,6 +77,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
$label = \str_replace($find, $replacement, $label);
}
}
return $label;
};
@@ -160,7 +163,7 @@ Http::init()
$scopes = $roles[$role]['scopes'];
// Step 5: API Key Authentication
if (!empty($apiKey)) {
if (! empty($apiKey)) {
// Check if key is expired
if ($apiKey->isExpired()) {
throw new Exception(Exception::PROJECT_KEY_EXPIRED);
@@ -170,7 +173,6 @@ Http::init()
$role = $apiKey->getRole();
$scopes = $apiKey->getScopes();
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Disable authorization checks for project API keys
@@ -193,19 +195,19 @@ Http::init()
// For standard keys, update last accessed time
if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) {
$dbKey = null;
if (!empty($apiKey->getProjectId())) {
if (! empty($apiKey->getProjectId())) {
$dbKey = $project->find(
key: 'secret',
find: $request->getHeader('x-appwrite-key', ''),
subject: 'keys'
);
} elseif (!empty($apiKey->getUserId())) {
} elseif (! empty($apiKey->getUserId())) {
$dbKey = $user->find(
key: 'secret',
find: $request->getHeader('x-appwrite-key', ''),
subject: 'keys'
);
} elseif (!empty($apiKey->getTeamId())) {
} elseif (! empty($apiKey->getTeamId())) {
$dbKey = $team->find(
key: 'secret',
find: $request->getHeader('x-appwrite-key', ''),
@@ -214,8 +216,6 @@ Http::init()
}
if (!$dbKey) {
\var_dump($apiKey);
\var_dump($request->getHeader('x-appwrite-key', ''));
throw new Exception(Exception::USER_UNAUTHORIZED);
}
@@ -233,7 +233,7 @@ Http::init()
if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
$sdks = $dbKey->getAttribute('sdks', []);
if (!in_array($sdk, $sdks)) {
if (! in_array($sdk, $sdks)) {
$sdks[] = $sdk;
$updates->setAttribute('sdks', $sdks);
@@ -241,14 +241,14 @@ Http::init()
}
}
if (!$updates->isEmpty()) {
if (! $updates->isEmpty()) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates));
if (!empty($apiKey->getProjectId())) {
if (! empty($apiKey->getProjectId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
} elseif (!empty($apiKey->getUserId())) {
} elseif (! empty($apiKey->getUserId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId()));
} elseif (!empty($apiKey->getTeamId())) {
} elseif (! empty($apiKey->getTeamId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId()));
}
}
@@ -285,7 +285,7 @@ Http::init()
}
}
} // Admin User Authentication
elseif (($project->getId() === 'console' && !$team->isEmpty() && !$user->isEmpty()) || ($project->getId() !== 'console' && !$user->isEmpty() && $mode === APP_MODE_ADMIN)) {
elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) {
$teamId = $team->getId();
$adminRoles = [];
$memberships = $user->getAttribute('memberships', []);
@@ -310,7 +310,7 @@ Http::init()
// Useful for those who have project-specific roles but don't have team-wide role.
$scopes = ['teams.read', 'projects.read'];
foreach ($adminRoles as $adminRole) {
$isTeamWideRole = !str_starts_with($adminRole, 'project-');
$isTeamWideRole = ! str_starts_with($adminRole, 'project-');
$isProjectSpecificRole = $projectId !== 'console' && str_starts_with($adminRole, 'project-' . $projectId);
if ($isTeamWideRole || $isProjectSpecificRole) {
@@ -348,18 +348,18 @@ Http::init()
* But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check
* whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them).
*/
if (empty($apiKey) && !$user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) {
if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) {
$input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ));
$initialStatus = $authorization->getStatus();
$authorization->enable();
if (!$authorization->isValid($input)) {
if (! $authorization->isValid($input)) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$authorization->setStatus($initialStatus);
}
// Step 6: Update project and user last activity
if (!$project->isEmpty() && $project->getId() !== 'console') {
if (! $project->isEmpty() && $project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
@@ -368,12 +368,12 @@ Http::init()
}
}
if (!empty($user->getId())) {
if (! empty($user->getId())) {
$accessedAt = $user->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) {
$user->setAttribute('accessedAt', DateTime::now());
if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) {
if ($project->getId() !== 'console' && $mode !== APP_MODE_ADMIN) {
$dbForProject->updateDocument('users', $user->getId(), new Document([
'accessedAt' => $user->getAttribute('accessedAt')
]));
@@ -397,26 +397,26 @@ Http::init()
$method = $method[0];
}
if (!empty($method)) {
if (! empty($method)) {
$namespace = $method->getNamespace();
if (
array_key_exists($namespace, $project->getAttribute('services', []))
&& !$project->getAttribute('services', [])[$namespace]
&& !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
&& ! $project->getAttribute('services', [])[$namespace]
&& ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
) {
throw new Exception(Exception::GENERAL_SERVICE_DISABLED);
}
}
// Step 9: Validate scope permissions
$allowed = (array)$route->getLabel('scope', 'none');
$allowed = (array) $route->getLabel('scope', 'none');
if (empty(\array_intersect($allowed, $scopes))) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')');
}
// Step 10: Check if user is blocked
if (false === $user->getAttribute('status')) { // Account is blocked
if ($user->getAttribute('status') === false) { // Account is blocked
throw new Exception(Exception::USER_BLOCKED);
}
@@ -434,7 +434,7 @@ Http::init()
$minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1;
// Step 13: Handle Multi-Factor Authentication
if (!in_array('mfa', $route->getGroups())) {
if (! in_array('mfa', $route->getGroups())) {
if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) {
throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED);
}
@@ -454,7 +454,7 @@ Http::init()
->inject('queueForDeletes')
->inject('queueForDatabase')
->inject('queueForBuilds')
->inject('queueForStatsUsage')
->inject('usage')
->inject('queueForFunctions')
->inject('queueForMails')
->inject('dbForProject')
@@ -467,14 +467,14 @@ Http::init()
->inject('telemetry')
->inject('platform')
->inject('authorization')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
$route = $utopia->getRoute();
if (
array_key_exists('rest', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['rest']
&& !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
&& ! $project->getAttribute('apis', [])['rest']
&& ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -486,7 +486,7 @@ Http::init()
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
$timeLimitArray = [];
$abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
foreach ($abuseKeyLabel as $abuseKey) {
$start = $request->getContentRangeStart();
@@ -499,7 +499,7 @@ Http::init()
->setParam('{ip}', $request->getIP())
->setParam('{url}', $request->getHostname() . $route->getPath())
->setParam('{method}', $request->getMethod())
->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
$timeLimitArray[] = $timeLimit;
}
@@ -511,7 +511,7 @@ Http::init()
foreach ($timeLimitArray as $timeLimit) {
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
if (!empty($value)) {
if (! empty($value)) {
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
}
}
@@ -534,8 +534,8 @@ Http::init()
if (
$enabled // Abuse is enabled
&& !$isAppUser // User is not API key
&& !$isPrivilegedUser // User is not an admin
&& ! $isAppUser // User is not API key
&& ! $isPrivilegedUser // User is not an admin
&& $devKey->isEmpty() // request doesn't not contain development key
&& $abuse->check() // Route is rate-limited
) {
@@ -564,19 +564,13 @@ Http::init()
->setProject($project);
/* If a session exists, use the user associated with the session */
if (!$user->isEmpty()) {
if (! $user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
$queueForAudits->setUser($userClone);
}
if (!empty($apiKey) && !empty($apiKey->getDisabledMetrics())) {
foreach ($apiKey->getDisabledMetrics() as $key) {
$queueForStatsUsage->disableMetric($key);
}
}
/* Auto-set projects */
$queueForDeletes->setProject($project);
$queueForDatabase->setProject($project);
@@ -590,69 +584,64 @@ Http::init()
$queueForBuilds->setPlatform($platform);
$queueForMails->setPlatform($platform);
$useCache = $route->getLabel('cache', false);
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
if ($useCache) {
$route = $utopia->match($request);
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !User::isPrivileged($authorization->getRoles());
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! User::isPrivileged($authorization->getRoles());
$key = $request->cacheIdentifier();
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
$cache = new Cache(
new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId())
);
$timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched.
$data = $cache->load($key, $timestamp);
if (!empty($data) && !$cacheLog->isEmpty()) {
$usageMetric = $route->getLabel('usage.metric', null);
if ($usageMetric === METRIC_AVATARS_SCREENSHOTS_GENERATED) {
$queueForStatsUsage->disableMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED);
}
if (! empty($data) && ! $cacheLog->isEmpty()) {
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
$type = $parts[0] ?? null;
if ($type === 'bucket' && (!$isImageTransformation || !$isDisabled)) {
if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) {
$bucketId = $parts[1] ?? null;
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
$isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAppUser && !$isPrivilegedUser)) {
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
if (!$bucket->getAttribute('transformations', true) && !$isAppUser && !$isPrivilegedUser) {
if (! $bucket->getAttribute('transformations', true) && ! $isAppUser && ! $isPrivilegedUser) {
throw new Exception(Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED);
}
$fileSecurity = $bucket->getAttribute('fileSecurity', false);
$valid = $authorization->isValid(new Input(Database::PERMISSION_READ, $bucket->getRead()));
if (!$fileSecurity && !$valid && !$isToken) {
if (! $fileSecurity && ! $valid && ! $isToken) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}
$parts = explode('/', $cacheLog->getAttribute('resource'));
$fileId = $parts[1] ?? null;
if ($fileSecurity && !$valid && !$isToken) {
if ($fileSecurity && ! $valid && ! $isToken) {
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
} else {
$file = $authorization->skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
}
if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) {
if (! $resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}
if ($file->isEmpty()) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
//Do not update transformedAt if it's a console user
if (!User::isPrivileged($authorization->getRoles())) {
// Do not update transformedAt if it's a console user
if (! User::isPrivileged($authorization->getRoles())) {
$transformedAt = $file->getAttribute('transformedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
$file->setAttribute('transformedAt', DateTime::now());
@@ -668,7 +657,7 @@ Http::init()
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($cacheLog->getAttribute('mimeType'));
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
if (!$isImageTransformation || !$isDisabled) {
if (! $isImageTransformation || ! $isDisabled) {
$response->send($data);
}
} else {
@@ -691,7 +680,7 @@ Http::init()
return;
}
if (!$user->isEmpty()) {
if (! $user->isEmpty()) {
throw new Exception(Exception::USER_SESSION_ALREADY_EXISTS);
}
});
@@ -745,7 +734,8 @@ Http::shutdown()
->inject('user')
->inject('queueForEvents')
->inject('queueForAudits')
->inject('queueForStatsUsage')
->inject('usage')
->inject('publisherForUsage')
->inject('queueForDeletes')
->inject('queueForDatabase')
->inject('queueForBuilds')
@@ -758,11 +748,12 @@ Http::shutdown()
->inject('timelimit')
->inject('eventProcessor')
->inject('bus')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus) use ($parseLabel) {
->inject('apiKey')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey) use ($parseLabel) {
$responsePayload = $response->getPayload();
if (!empty($queueForEvents->getEvent())) {
if (! empty($queueForEvents->getEvent())) {
if (empty($queueForEvents->getPayload())) {
$queueForEvents->setPayload($responsePayload);
}
@@ -784,7 +775,7 @@ Http::shutdown()
}
// Only trigger functions if there are matching function events
if (!empty($functionsEvents)) {
if (! empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
$queueForFunctions
@@ -796,7 +787,7 @@ Http::shutdown()
}
// Only trigger webhooks if there are matching webhook events
if (!empty($webhooksEvents)) {
if (! empty($webhooksEvents)) {
foreach ($generatedEvents as $event) {
if (isset($webhooksEvents[$event])) {
$queueForWebhooks
@@ -820,7 +811,7 @@ Http::shutdown()
if ($abuseEnabled && \count($abuseResetCode) > 0 && \in_array($response->getStatusCode(), $abuseResetCode)) {
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
$abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
foreach ($abuseKeyLabel as $abuseKey) {
$start = $request->getContentRangeStart();
@@ -833,10 +824,10 @@ Http::shutdown()
->setParam('{ip}', $request->getIP())
->setParam('{url}', $request->getHostname() . $route->getPath())
->setParam('{method}', $request->getMethod())
->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
if (!empty($value)) {
if (! empty($value)) {
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
}
}
@@ -850,14 +841,14 @@ Http::shutdown()
* Audit labels
*/
$pattern = $route->getLabel('audits.resource', null);
if (!empty($pattern)) {
if (! empty($pattern)) {
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
if (!empty($resource) && $resource !== $pattern) {
if (! empty($resource) && $resource !== $pattern) {
$queueForAudits->setResource($resource);
}
}
if (!$user->isEmpty()) {
if (! $user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
@@ -883,13 +874,13 @@ Http::shutdown()
$queueForAudits->setUser($user);
}
if (!empty($queueForAudits->getResource()) && !$queueForAudits->getUser()->isEmpty()) {
if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) {
/**
* audits.payload is switched to default true
* in order to auto audit payload for all endpoints
*/
$pattern = $route->getLabel('audits.payload', true);
if (!empty($pattern)) {
if (! empty($pattern)) {
$queueForAudits->setPayload($responsePayload);
}
@@ -900,19 +891,19 @@ Http::shutdown()
$queueForAudits->trigger();
}
if (!empty($queueForDeletes->getType())) {
if (! empty($queueForDeletes->getType())) {
$queueForDeletes->trigger();
}
if (!empty($queueForDatabase->getType())) {
if (! empty($queueForDatabase->getType())) {
$queueForDatabase->trigger();
}
if (!empty($queueForBuilds->getType())) {
if (! empty($queueForBuilds->getType())) {
$queueForBuilds->trigger();
}
if (!empty($queueForMessaging->getType())) {
if (! empty($queueForMessaging->getType())) {
$queueForMessaging->trigger();
}
@@ -921,14 +912,14 @@ Http::shutdown()
if ($useCache) {
$resource = $resourceType = null;
$data = $response->getPayload();
if (!empty($data['payload'])) {
if (! empty($data['payload'])) {
$pattern = $route->getLabel('cache.resource', null);
if (!empty($pattern)) {
if (! empty($pattern)) {
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
}
$pattern = $route->getLabel('cache.resourceType', null);
if (!empty($pattern)) {
if (! empty($pattern)) {
$resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
}
@@ -938,7 +929,7 @@ Http::shutdown()
$key = $request->cacheIdentifier();
$signature = md5($data['payload']);
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
$now = DateTime::now();
if ($cacheLog->isEmpty()) {
@@ -971,7 +962,7 @@ Http::shutdown()
}
if ($project->getId() !== 'console') {
if (!User::isPrivileged($authorization->getRoles())) {
if (! User::isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
@@ -979,9 +970,32 @@ Http::shutdown()
));
}
$queueForStatsUsage
->setProject($project)
->trigger();
// Publish usage metrics if context has data
if (! $usage->isEmpty()) {
$metrics = $usage->getMetrics();
// Filter out API key disabled metrics using suffix pattern matching
$disabledMetrics = $apiKey?->getDisabledMetrics() ?? [];
if (! empty($disabledMetrics)) {
$metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) {
foreach ($disabledMetrics as $pattern) {
if (str_ends_with($metric['key'], $pattern)) {
return false;
}
}
return true;
}));
}
$message = new UsageMessage(
project: $project,
metrics: $metrics,
reduce: $usage->getReduce()
);
$publisherForUsage->enqueue($message);
}
}
});
+3
View File
@@ -581,6 +581,9 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
$action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD';
if (!empty($sdk)) {
if (\is_array($sdk)) {
$sdk = $sdk[0];
}
/** @var Appwrite\SDK\Method $sdk */
$action = $sdk->getNamespace() . '.' . $sdk->getMethodName();
} elseif ($route === null) {
+6
View File
@@ -362,6 +362,12 @@ const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated';
const METRIC_FUNCTIONS_RUNTIME = 'functions.runtimes.{runtime}';
const METRIC_SITES_FRAMEWORK = 'sites.frameworks.{framework}';
// Realtime metrics
const METRIC_REALTIME_CONNECTIONS = 'realtime.connections';
const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent';
const METRIC_REALTIME_INBOUND = 'realtime.inbound';
const METRIC_REALTIME_OUTBOUND = 'realtime.outbound';
// Resource types
const RESOURCE_TYPE_PROJECTS = 'projects';
const RESOURCE_TYPE_FUNCTIONS = 'functions';
+1
View File
@@ -420,6 +420,7 @@ $register->set('smtp', function () {
$mail->Password = $password;
$mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', '');
$mail->SMTPAutoTLS = false;
$mail->SMTPKeepAlive = true;
$mail->CharSet = 'UTF-8';
$mail->Timeout = 10; /* Connection timeout */
$mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */
+121 -113
View File
@@ -14,10 +14,10 @@ use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsResources;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
@@ -26,6 +26,7 @@ use Appwrite\Network\Cors;
use Appwrite\Network\Platform;
use Appwrite\Network\Validator\Origin;
use Appwrite\Network\Validator\Redirect;
use Appwrite\Usage\Context as UsageContext;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -57,6 +58,7 @@ use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Storage\Device;
use Utopia\Storage\Device\AWS;
use Utopia\Storage\Device\Backblaze;
@@ -88,6 +90,7 @@ Http::setResource('register', fn () => $register);
Http::setResource('locale', function () {
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
return $locale;
});
@@ -108,9 +111,6 @@ Http::setResource('publisherFunctions', function (Publisher $publisher) {
Http::setResource('publisherMigrations', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
Http::setResource('publisherStatsUsage', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
Http::setResource('publisherMails', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
@@ -150,9 +150,13 @@ Http::setResource('queueForWebhooks', function (Publisher $publisher) {
Http::setResource('queueForRealtime', function () {
return new Realtime();
}, []);
Http::setResource('queueForStatsUsage', function (Publisher $publisher) {
return new StatsUsage($publisher);
}, ['publisher']);
Http::setResource('usage', function () {
return new UsageContext();
}, []);
Http::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
Http::setResource('queueForAudits', function (Publisher $publisher) {
return new AuditEvent($publisher);
}, ['publisher']);
@@ -186,14 +190,14 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje
$allowed = [...($platform['hostnames'] ?? [])];
/* Add platform configured hostnames */
if (!$project->isEmpty() && $project->getId() !== 'console') {
if (! $project->isEmpty() && $project->getId() !== 'console') {
$platforms = $project->getAttribute('platforms', []);
$hostnames = Platform::getHostnames($platforms);
$allowed = [...$allowed, ...$hostnames];
}
/* Add the request hostname if a dev key is found */
if (!$devKey->isEmpty()) {
if (! $devKey->isEmpty()) {
$allowed[] = $request->getHostname();
}
@@ -211,12 +215,12 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje
}
/* Allow the request origin of rule */
if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) {
if (! $rule->isEmpty() && ! empty($rule->getAttribute('domain', ''))) {
$allowed[] = $rule->getAttribute('domain', '');
}
/* Allow the request origin if a dev key is found */
if (!$devKey->isEmpty() && !empty($hostname)) {
if (! $devKey->isEmpty() && ! empty($hostname)) {
$allowed[] = $hostname;
}
@@ -229,7 +233,7 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje
Http::setResource('allowedSchemes', function (array $platform, Document $project) {
$allowed = [...($platform['schemas'] ?? [])];
if (!$project->isEmpty() && $project->getId() !== 'console') {
if (! $project->isEmpty() && $project->getId() !== 'console') {
/* Add hardcoded schemes */
$allowed[] = 'exp';
$allowed[] = 'appwrite-callback-' . $project->getId();
@@ -273,7 +277,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D
// Temporary implementation until custom wildcard domains are an official feature
// Allow trusted projects; Used for Console (website) previews
if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) {
if (! $permitsCurrentProject && ! $rule->isEmpty() && ! empty($rule->getAttribute('projectId', ''))) {
$trustedProjects = [];
foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
if (empty($trustedProject)) {
@@ -286,7 +290,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D
}
}
if (!$permitsCurrentProject) {
if (! $permitsCurrentProject) {
return new Document();
}
@@ -309,16 +313,18 @@ Http::setResource('cors', function (array $allowedHostnames) {
}, ['allowedHostnames']);
Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
if (!$devKey->isEmpty()) {
if (! $devKey->isEmpty()) {
return new URL();
}
return new Origin($allowedHostnames, $allowedSchemes);
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
Http::setResource('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
if (!$devKey->isEmpty()) {
if (! $devKey->isEmpty()) {
return new URL();
}
return new Redirect($allowedHostnames, $allowedSchemes);
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
@@ -342,12 +348,11 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
* overwriting the previous value.
* 7. If account API key is passed, use user of the account API key as long as user ID header matches too
*/
$authorization->setDefaultStatus(true);
$store->setKey('a_session_' . $project->getId());
if (APP_MODE_ADMIN === $mode) {
if ($mode === APP_MODE_ADMIN) {
$store->setKey('a_session_' . $console->getId());
}
@@ -362,7 +367,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
$sessionHeader = $request->getHeader('x-appwrite-session', '');
if (!empty($sessionHeader)) {
if (! empty($sessionHeader)) {
$store->decode($sessionHeader);
}
}
@@ -382,14 +387,14 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
}
$user = null;
if (APP_MODE_ADMIN === $mode) {
if ($mode === APP_MODE_ADMIN) {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
if ($project->isEmpty()) {
$user = new User([]);
} else {
if (!empty($store->getProperty('id', ''))) {
if (! empty($store->getProperty('id', ''))) {
if ($project->getId() === 'console') {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
@@ -402,16 +407,16 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
}
if (
!$user ||
! $user ||
$user->isEmpty() // Check a document has been found in the DB
|| !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
|| ! $user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
) { // Validate user has valid login token
$user = new User([]);
}
$authJWT = $request->getHeader('x-appwrite-jwt', '');
if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication
if (!$user->isEmpty()) {
if (! empty($authJWT) && ! $project->isEmpty()) { // JWT authentication
if (! $user->isEmpty()) {
throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
}
@@ -423,7 +428,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
}
$jwtUserId = $payload['userId'] ?? '';
if (!empty($jwtUserId)) {
if (! empty($jwtUserId)) {
if ($mode === APP_MODE_ADMIN) {
$user = $dbForPlatform->getDocument('users', $jwtUserId);
} else {
@@ -431,7 +436,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
}
}
$jwtSessionId = $payload['sessionId'] ?? '';
if (!empty($jwtSessionId)) {
if (! empty($jwtSessionId)) {
if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
$user = new User([]);
}
@@ -441,22 +446,22 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
// Account based on account API key
$accountKey = $request->getHeader('x-appwrite-key', '');
$accountKeyUserId = $request->getHeader('x-appwrite-user', '');
if (!empty($accountKeyUserId) && !empty($accountKey)) {
if (!$user->isEmpty()) {
if (! empty($accountKeyUserId) && ! empty($accountKey)) {
if (! $user->isEmpty()) {
throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
}
$accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
if (!$accountKeyUser->isEmpty()) {
if (! $accountKeyUser->isEmpty()) {
$key = $accountKeyUser->find(
key: 'secret',
find: $accountKey,
subject: 'keys'
);
if (!empty($key)) {
if (! empty($key)) {
$expire = $key->getAttribute('expire');
if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
}
@@ -475,10 +480,9 @@ Http::setResource('project', function ($dbForPlatform, $request, $console, $auth
/** @var Appwrite\Utopia\Request $request */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var Utopia\Database\Document $console */
$projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', ''));
// Realtime channel "project" can send project=Query array
if (!\is_string($projectId)) {
if (! \is_string($projectId)) {
$projectId = $request->getHeader('x-appwrite-project', '');
}
@@ -499,7 +503,7 @@ Http::setResource('session', function (User $user, Store $store, Token $proofFor
$sessions = $user->getAttribute('sessions', []);
$sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
if (!$sessionId) {
if (! $sessionId) {
return;
}
foreach ($sessions as $session) {
@@ -509,7 +513,6 @@ Http::setResource('session', function (User $user, Store $store, Token $proofFor
}
}
return;
}, ['user', 'store', 'proofForToken']);
Http::setResource('store', function (): Store {
@@ -533,12 +536,14 @@ Http::setResource('proofForPassword', function (): Password {
Http::setResource('proofForToken', function (): Token {
$token = new Token();
$token->setHash(new Sha());
return $token;
});
Http::setResource('proofForCode', function (): Code {
$code = new Code();
$code->setHash(new Sha());
return $code;
});
@@ -550,7 +555,7 @@ Http::setResource('authorization', function () {
return new Authorization();
}, []);
Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Authorization $authorization) {
Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
@@ -584,7 +589,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int) $project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -615,9 +620,8 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
->from($queueForEvents)
->trigger();
/** Trigger webhooks events only if a project has them enabled */
if (!empty($project->getAttribute('webhooks'))) {
if (! empty($project->getAttribute('webhooks'))) {
$queueForWebhooks
->from($queueForEvents)
->trigger();
@@ -636,7 +640,6 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
*/
$functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) {
if ($document->getCollection() !== 'functions') {
return;
}
@@ -658,7 +661,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
$dbForProject->getCache()->purge($cacheKey);
};
$usageDatabaseListener = function (string $event, Document $document, StatsUsage $queueForStatsUsage) {
$usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) {
$value = 1;
switch ($event) {
@@ -678,81 +681,78 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
switch (true) {
case $document->getCollection() === 'teams':
$queueForStatsUsage->addMetric(METRIC_TEAMS, $value); // per project
$usage->addMetric(METRIC_TEAMS, $value); // per project
break;
case $document->getCollection() === 'users':
$queueForStatsUsage->addMetric(METRIC_USERS, $value); // per project
$usage->addMetric(METRIC_USERS, $value); // per project
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage->addReduce($document);
$usage->addReduce($document);
}
break;
case $document->getCollection() === 'sessions': // sessions
$queueForStatsUsage->addMetric(METRIC_SESSIONS, $value); //per project
$usage->addMetric(METRIC_SESSIONS, $value); // per project
break;
case $document->getCollection() === 'databases': // databases
$queueForStatsUsage->addMetric(METRIC_DATABASES, $value); // per project
$usage->addMetric(METRIC_DATABASES, $value); // per project
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage->addReduce($document);
$usage->addReduce($document);
}
break;
case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections
case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections
$parts = explode('_', $document->getCollection());
$databaseInternalId = $parts[1] ?? 0;
$queueForStatsUsage
$usage
->addMetric(METRIC_COLLECTIONS, $value) // per project
->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), $value);
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage->addReduce($document);
$usage->addReduce($document);
}
break;
case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): //documents
case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents
$parts = explode('_', $document->getCollection());
$databaseInternalId = $parts[1] ?? 0;
$databaseInternalId = $parts[1] ?? 0;
$collectionInternalId = $parts[3] ?? 0;
$queueForStatsUsage
$usage
->addMetric(METRIC_DOCUMENTS, $value) // per project
->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $value) // per database
->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), $value); // per collection
break;
case $document->getCollection() === 'buckets': //buckets
$queueForStatsUsage
->addMetric(METRIC_BUCKETS, $value); // per project
case $document->getCollection() === 'buckets': // buckets
$usage->addMetric(METRIC_BUCKETS, $value); // per project
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage
$usage
->addReduce($document);
}
break;
case str_starts_with($document->getCollection(), 'bucket_'): // files
$parts = explode('_', $document->getCollection());
$bucketInternalId = $parts[1];
$queueForStatsUsage
$bucketInternalId = $parts[1];
$usage
->addMetric(METRIC_FILES, $value) // per project
->addMetric(METRIC_FILES_STORAGE, $document->getAttribute('sizeOriginal') * $value) // per project
->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES), $value) // per bucket
->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES_STORAGE), $document->getAttribute('sizeOriginal') * $value); // per bucket
break;
case $document->getCollection() === 'functions':
$queueForStatsUsage
->addMetric(METRIC_FUNCTIONS, $value); // per project
$usage->addMetric(METRIC_FUNCTIONS, $value); // per project
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage
$usage
->addReduce($document);
}
break;
case $document->getCollection() === 'sites':
$queueForStatsUsage
->addMetric(METRIC_SITES, $value); // per project
$usage->addMetric(METRIC_SITES, $value); // per project
if ($event === Database::EVENT_DOCUMENT_DELETE) {
$queueForStatsUsage
$usage
->addReduce($document);
}
break;
case $document->getCollection() === 'deployments':
$queueForStatsUsage
$usage
->addMetric(METRIC_DEPLOYMENTS, $value) // per project
->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project
->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS), $value) // per function
@@ -772,30 +772,27 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
$queueForWebhooks = new Webhook($publisherWebhooks);
$queueForRealtime = new Realtime();
$database
->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener(
$project,
$document,
$response,
$queueForEventsClone->from($queueForEvents),
$queueForFunctions->from($queueForEvents),
$queueForWebhooks->from($queueForEvents),
$queueForRealtime->from($queueForEvents)
))
->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
;
->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener(
$project,
$document,
$response,
$queueForEventsClone->from($queueForEvents),
$queueForFunctions->from($queueForEvents),
$queueForWebhooks->from($queueForEvents),
$queueForRealtime->from($queueForEvents)
))
->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database));
return $database;
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'queueForStatsUsage', 'authorization']);
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization']);
Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
@@ -844,15 +841,14 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor
->setMetadata('project', $project->getId())
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES)
->setDocumentType('users', User::class)
;
->setDocumentType('users', User::class);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int) $project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -865,6 +861,7 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor
if (isset($databases[$dsn->getHost()])) {
$database = $databases[$dsn->getHost()];
$configure($database);
return $database;
}
@@ -882,7 +879,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant((int) $project->getSequence());
$database->setTenant($project->getSequence());
return $database;
}
@@ -899,7 +896,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
// set tenant
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant((int) $project->getSequence());
$database->setTenant($project->getSequence());
}
return $database;
@@ -908,6 +905,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
Http::setResource('audit', function ($dbForProject) {
$adapter = new AdapterDatabase($dbForProject);
return new Audit($adapter);
}, ['dbForProject']);
@@ -923,6 +921,7 @@ Http::setResource('cache', function (Group $pools, Telemetry $telemetry) {
$cache = new Cache(new Sharding($adapters));
$cache->setTelemetry($telemetry);
return $cache;
}, ['pools', 'telemetry']);
@@ -968,9 +967,9 @@ Http::setResource('deviceForBuilds', function ($project, Telemetry $telemetry) {
function getDevice(string $root, string $connection = ''): Device
{
$connection = !empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', '');
$connection = ! empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', '');
if (!empty($connection)) {
if (! empty($connection)) {
$acl = 'private';
$device = Storage::DEVICE_LOCAL;
$accessKey = '';
@@ -992,8 +991,9 @@ function getDevice(string $root, string $connection = ''): Device
switch ($device) {
case Storage::DEVICE_S3:
if (!empty($url)) {
$bucketRoot = (!empty($bucket) ? $bucket . '/' : '') . \ltrim($root, '/');
if (! empty($url)) {
$bucketRoot = (! empty($bucket) ? $bucket . '/' : '') . \ltrim($root, '/');
return new S3($bucketRoot, $accessKey, $accessSecret, $url, $region, $acl);
} else {
return new AWS($root, $accessKey, $accessSecret, $bucket, $region, $acl);
@@ -1002,6 +1002,7 @@ function getDevice(string $root, string $connection = ''): Device
case STORAGE::DEVICE_DO_SPACES:
$device = new DOSpaces($root, $accessKey, $accessSecret, $bucket, $region, $acl);
$device->setHttpVersion(S3::HTTP_VERSION_1_1);
return $device;
case Storage::DEVICE_BACKBLAZE:
return new Backblaze($root, $accessKey, $accessSecret, $bucket, $region, $acl);
@@ -1025,8 +1026,9 @@ function getDevice(string $root, string $connection = ''): Device
$s3Bucket = System::getEnv('_APP_STORAGE_S3_BUCKET', '');
$s3Acl = 'private';
$s3EndpointUrl = System::getEnv('_APP_STORAGE_S3_ENDPOINT', '');
if (!empty($s3EndpointUrl)) {
$bucketRoot = (!empty($s3Bucket) ? $s3Bucket . '/' : '') . \ltrim($root, '/');
if (! empty($s3EndpointUrl)) {
$bucketRoot = (! empty($s3Bucket) ? $s3Bucket . '/' : '') . \ltrim($root, '/');
return new S3($bucketRoot, $s3AccessKey, $s3SecretKey, $s3EndpointUrl, $s3Region, $s3Acl);
} else {
return new AWS($root, $s3AccessKey, $s3SecretKey, $s3Bucket, $s3Region, $s3Acl);
@@ -1040,6 +1042,7 @@ function getDevice(string $root, string $connection = ''): Device
$doSpacesAcl = 'private';
$device = new DOSpaces($root, $doSpacesAccessKey, $doSpacesSecretKey, $doSpacesBucket, $doSpacesRegion, $doSpacesAcl);
$device->setHttpVersion(S3::HTTP_VERSION_1_1);
return $device;
case Storage::DEVICE_BACKBLAZE:
$backblazeAccessKey = System::getEnv('_APP_STORAGE_BACKBLAZE_ACCESS_KEY', '');
@@ -1047,6 +1050,7 @@ function getDevice(string $root, string $connection = ''): Device
$backblazeRegion = System::getEnv('_APP_STORAGE_BACKBLAZE_REGION', '');
$backblazeBucket = System::getEnv('_APP_STORAGE_BACKBLAZE_BUCKET', '');
$backblazeAcl = 'private';
return new Backblaze($root, $backblazeAccessKey, $backblazeSecretKey, $backblazeBucket, $backblazeRegion, $backblazeAcl);
case Storage::DEVICE_LINODE:
$linodeAccessKey = System::getEnv('_APP_STORAGE_LINODE_ACCESS_KEY', '');
@@ -1054,6 +1058,7 @@ function getDevice(string $root, string $connection = ''): Device
$linodeRegion = System::getEnv('_APP_STORAGE_LINODE_REGION', '');
$linodeBucket = System::getEnv('_APP_STORAGE_LINODE_BUCKET', '');
$linodeAcl = 'private';
return new Linode($root, $linodeAccessKey, $linodeSecretKey, $linodeBucket, $linodeRegion, $linodeAcl);
case Storage::DEVICE_WASABI:
$wasabiAccessKey = System::getEnv('_APP_STORAGE_WASABI_ACCESS_KEY', '');
@@ -1061,6 +1066,7 @@ function getDevice(string $root, string $connection = ''): Device
$wasabiRegion = System::getEnv('_APP_STORAGE_WASABI_REGION', '');
$wasabiBucket = System::getEnv('_APP_STORAGE_WASABI_BUCKET', '');
$wasabiAcl = 'private';
return new Wasabi($root, $wasabiAccessKey, $wasabiSecretKey, $wasabiBucket, $wasabiRegion, $wasabiAcl);
}
}
@@ -1087,7 +1093,6 @@ Http::setResource('passwordsDictionary', function ($register) {
return $register->get('passwordsDictionary');
}, ['register']);
Http::setResource('servers', function () {
$platforms = Config::getParam('sdks');
$server = $platforms[APP_SDK_PLATFORM_SERVER];
@@ -1195,16 +1200,17 @@ Http::setResource('gitHub', function (Cache $cache) {
}, ['cache']);
Http::setResource('requestTimestamp', function ($request) {
//TODO: Move this to the Request class itself
// TODO: Move this to the Request class itself
$timestampHeader = $request->getHeader('x-appwrite-timestamp');
$requestTimestamp = null;
if (!empty($timestampHeader)) {
if (! empty($timestampHeader)) {
try {
$requestTimestamp = new \DateTime($timestampHeader);
} catch (\Throwable $e) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid X-Appwrite-Timestamp header value');
}
}
return $requestTimestamp;
}, ['request']);
@@ -1221,13 +1227,13 @@ Http::setResource('devKey', function (Request $request, Document $project, array
// Check if given key match project's development keys
$key = $project->find('secret', $devKey, 'devKeys');
if (!$key) {
if (! $key) {
return new Document([]);
}
// check expiration
$expire = $key->getAttribute('expire');
if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
return new Document([]);
}
@@ -1248,7 +1254,7 @@ Http::setResource('devKey', function (Request $request, Document $project, array
if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
$sdks = $key->getAttribute('sdks', []);
if (!in_array($sdk, $sdks)) {
if (! in_array($sdk, $sdks)) {
$sdks[] = $sdk;
$key->setAttribute('sdks', $sdks);
@@ -1271,7 +1277,7 @@ Http::setResource('team', function (Document $project, Database $dbForPlatform,
$teamInternalId = $project->getAttribute('teamInternalId', '');
} else {
$route = $utopia->match($request);
$path = !empty($route) ? $route->getPath() : $request->getURI();
$path = ! empty($route) ? $route->getPath() : $request->getURI();
$orgHeader = $request->getHeader('x-appwrite-organization', '');
if (str_starts_with($path, '/v1/projects/:projectId')) {
$uri = $request->getURI();
@@ -1286,8 +1292,9 @@ Http::setResource('team', function (Document $project, Database $dbForPlatform,
}
$team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));
return $team;
} elseif (!empty($orgHeader)) {
} elseif (! empty($orgHeader)) {
return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
}
}
@@ -1317,13 +1324,13 @@ Http::setResource('previewHostname', function (Request $request, ?Key $apiKey) {
if (Http::isDevelopment()) {
$allowed = true;
} elseif (!\is_null($apiKey) && $apiKey->getHostnameOverride() === true) {
} elseif (! \is_null($apiKey) && $apiKey->getHostnameOverride() === true) {
$allowed = true;
}
if ($allowed) {
$host = $request->getQuery('appwrite-hostname', $request->getHeader('x-appwrite-hostname', '')) ?? '';
if (!empty($host)) {
if (! empty($host)) {
return $host;
}
}
@@ -1344,19 +1351,19 @@ Http::setResource('apiKey', function (Request $request, Document $project, Docum
$organizationHeader = $request->getHeader('x-appwrite-organization');
$projectHeader = $request->getHeader('x-appwrite-project');
if (!empty($key->getProjectId())) {
if (! empty($key->getProjectId())) {
if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) {
throw new Exception(Exception::PROJECT_ID_MISSING);
}
}
if (!empty($key->getUserId())) {
if (! empty($key->getUserId())) {
if (empty($userHeader) || $userHeader !== $key->getUserId()) {
throw new Exception(Exception::USER_ID_MISSING);
}
}
if (!empty($key->getTeamId())) {
if (! empty($key->getTeamId())) {
if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) {
throw new Exception(Exception::ORGANIZATION_ID_MISSING);
}
@@ -1370,7 +1377,7 @@ Http::setResource('executor', fn () => new Executor());
Http::setResource('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
$tokenJWT = $request->getParam('token');
if (!empty($tokenJWT) && !$project->isEmpty()) { // JWT authentication
if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication
// Use a large but reasonable maxAge to avoid auto-exp when token has no expiry
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // Instantiate with key, algo, maxAge and leeway.
@@ -1430,6 +1437,7 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request,
default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID),
};
}
return new Document([]);
}, ['project', 'dbForProject', 'request', 'authorization']);
+6 -1
View File
@@ -5,4 +5,9 @@ use Utopia\Span\Span;
use Utopia\Span\Storage;
Span::setStorage(new Storage\Coroutine());
Span::addExporter(new Exporter\Pretty());
Span::addExporter(new Exporter\Pretty(), function (Span $span): bool {
if (\str_starts_with($span->getAction(), 'listener.')) {
return $span->getError() !== null;
}
return true;
});
+116 -14
View File
@@ -50,6 +50,16 @@ require_once __DIR__ . '/init.php';
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
// Log uncaught exceptions in one line instead of relying on Swoole's full backtrace dump
set_exception_handler(function (\Throwable $e) {
Console::error(sprintf(
'Realtime uncaught exception: %s in %s:%d',
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
});
// Allows overriding
if (!function_exists('getConsoleDB')) {
function getConsoleDB(): Database
@@ -115,7 +125,7 @@ if (!function_exists('getProjectDB')) {
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int)$project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -224,6 +234,13 @@ if (!function_exists('getTelemetry')) {
}
}
if (!function_exists('triggerStats')) {
function triggerStats(array $event, string $projectId): void
{
return;
}
}
$realtime = getRealtime();
/**
@@ -548,20 +565,41 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
}
$total = 0;
$outboundBytes = 0;
foreach ($groups as $group) {
$data = $event['data'];
$data['subscriptions'] = $group['subscriptions'];
$server->send($group['ids'], json_encode([
$payloadJson = json_encode([
'type' => 'event',
'data' => $data
]));
$total += count($group['ids']);
]);
$server->send($group['ids'], $payloadJson);
$count = count($group['ids']);
$total += $count;
$outboundBytes += strlen($payloadJson) * $count;
}
if ($total > 0) {
$register->get('telemetry.messageSentCounter')->add($total);
$stats->incr($event['project'], 'messages', $total);
$projectId = $event['project'] ?? null;
if (!empty($projectId)) {
$metrics = [
METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total,
];
if ($outboundBytes > 0) {
$metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes;
}
triggerStats($metrics, $projectId);
}
}
});
} catch (Throwable $th) {
@@ -638,6 +676,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests');
}
$rawSize = $request->getSize();
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
], $project->getId());
/*
* Validate Client Domain - Check to avoid CSRF attack.
* Adding Appwrite API domains to allow XDOMAIN communication.
@@ -692,14 +736,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$user = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT);
$server->send([$connection], json_encode([
$connectedPayloadJson = json_encode([
'type' => 'connected',
'data' => [
'channels' => $names,
'subscriptions' => $mapping,
'user' => $user
]
]));
]);
$server->send([$connection], $connectedPayloadJson);
$register->get('telemetry.connectionCounter')->add(1);
$register->get('telemetry.connectionCreatedCounter')->add(1);
@@ -710,6 +756,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]);
$stats->incr($project->getId(), 'connections');
$stats->incr($project->getId(), 'connectionsTotal');
$connectedOutboundBytes = \strlen($connectedPayloadJson);
triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId());
} catch (Throwable $th) {
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
@@ -751,6 +803,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$authorization = null;
try {
$rawSize = \strlen($message);
$response = new Response(new SwooleResponse());
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
@@ -763,7 +816,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$database = getConsoleDB();
$database->setAuthorization($authorization);
if ($projectId !== 'console') {
if (!empty($projectId) && $projectId !== 'console') {
$project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
$database = getProjectDB($project);
@@ -789,17 +842,41 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many messages.');
}
// Record realtime inbound bytes for this project
if ($project !== null && !$project->isEmpty()) {
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
], $project->getId());
}
$message = json_decode($message, true);
if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.');
}
// Ping does not require project context; other messages do (e.g. after unsubscribe during auth)
if (empty($projectId) && ($message['type'] ?? '') !== 'ping') {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.');
}
switch ($message['type']) {
case 'ping':
$server->send([$connection], json_encode([
$pongPayloadJson = json_encode([
'type' => 'pong'
]));
]);
$server->send([$connection], $pongPayloadJson);
if ($project !== null && !$project->isEmpty()) {
$pongOutboundBytes = \strlen($pongPayloadJson);
if ($pongOutboundBytes > 0) {
triggerStats([
METRIC_REALTIME_OUTBOUND => $pongOutboundBytes,
], $project->getId());
}
}
break;
case 'authentication':
@@ -860,14 +937,27 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
}
$user = $response->output($user, Response::MODEL_ACCOUNT);
$server->send([$connection], json_encode([
$authResponsePayloadJson = json_encode([
'type' => 'response',
'data' => [
'to' => 'authentication',
'success' => true,
'user' => $user
]
]));
]);
$server->send([$connection], $authResponsePayloadJson);
if ($project !== null && !$project->isEmpty()) {
$authOutboundBytes = \strlen($authResponsePayloadJson);
if ($authOutboundBytes > 0) {
triggerStats([
METRIC_REALTIME_OUTBOUND => $authOutboundBytes,
], $project->getId());
}
}
break;
@@ -905,9 +995,21 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
});
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
if (array_key_exists($connection, $realtime->connections)) {
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
$register->get('telemetry.connectionCounter')->add(-1);
try {
if (array_key_exists($connection, $realtime->connections)) {
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
$register->get('telemetry.connectionCounter')->add(-1);
$projectId = $realtime->connections[$connection]['projectId'];
triggerStats([
METRIC_REALTIME_CONNECTIONS => -1,
], $projectId);
}
} catch (\Throwable $th) {
// Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine
// backtraces and unsubscribe() below would never run (connection cleanup would fail).
Console::error('Realtime onClose error: ' . $th->getMessage());
}
$realtime->unsubscribe($connection);
+106 -43
View File
@@ -12,8 +12,12 @@ $version = $this->getParam('version', '');
$organization = $this->getParam('organization', '');
$image = $this->getParam('image', '');
$enableAssistant = $this->getParam('enableAssistant', false);
$dbService = $this->getParam('database');
$dbService = $this->getParam('database', 'mongodb');
$allowedDbServices = ['mariadb', 'mongodb', 'postgresql'];
if (!\in_array($dbService, $allowedDbServices, true)) {
$dbService = 'mongodb';
}
$hostPath = rtrim($this->getParam('hostPath', ''), '/');
?>services:
traefik:
image: traefik:3.6
@@ -63,6 +67,9 @@ $dbService = $this->getParam('database');
- traefik.http.routers.appwrite_api_https.service=appwrite_api
- traefik.http.routers.appwrite_api_https.tls=true
volumes:
<?php if ($version === 'local' && !empty($hostPath)): ?>
- "<?php echo $hostPath; ?>:/usr/src/code:rw"
<?php endif; ?>
- appwrite-uploads:/storage/uploads:rw
- appwrite-imports:/storage/imports:rw
- appwrite-cache:/storage/cache:rw
@@ -72,8 +79,10 @@ $dbService = $this->getParam('database');
- appwrite-sites:/storage/sites:rw
- appwrite-builds:/storage/builds:rw
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
# - clamav
environment:
- _APP_ENV
@@ -227,8 +236,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -258,8 +269,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -286,8 +299,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -316,8 +331,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@@ -381,8 +398,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -409,8 +428,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
volumes:
- appwrite-functions:/storage/functions:rw
- appwrite-sites:/storage/sites:rw
@@ -479,8 +500,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
volumes:
- appwrite-config:/storage/config:rw
- appwrite-certificates:/storage/certificates:rw
@@ -518,9 +541,12 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
- openruntimes-executor
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
openruntimes-executor:
condition: service_started
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -559,8 +585,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -598,8 +626,10 @@ $dbService = $this->getParam('database');
volumes:
- appwrite-uploads:/storage/uploads:rw
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -652,7 +682,8 @@ $dbService = $this->getParam('database');
volumes:
- appwrite-imports:/storage/imports:rw
depends_on:
- <?= $dbService . "\n" ?>
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -688,8 +719,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -730,8 +763,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -761,8 +796,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -791,8 +828,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- redis
- <?= $dbService . "\n" ?>
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -821,8 +860,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- <?= $dbService . "\n" ?>
- redis
<?= $dbService ?>:
condition: service_healthy
redis:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -848,8 +889,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- <?= $dbService . "\n" ?>
- redis
<?= $dbService ?>:
condition: service_healthy
redis:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -875,8 +918,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- <?= $dbService . "\n" ?>
- redis
<?= $dbService ?>:
condition: service_healthy
redis:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -966,7 +1011,6 @@ $dbService = $this->getParam('database');
- OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET
<?php if ($dbService === 'mariadb'): ?>
mariadb:
image: mariadb:10.11
container_name: appwrite-mariadb
@@ -982,6 +1026,12 @@ $dbService = $this->getParam('database');
- MYSQL_PASSWORD=${_APP_DB_PASS}
- MARIADB_AUTO_UPGRADE=1
command: 'mysqld --innodb-flush-method=fsync'
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
<?php elseif ($dbService === 'mongodb'): ?>
@@ -1059,18 +1109,24 @@ $dbService = $this->getParam('database');
<?php elseif ($dbService === 'postgresql'): ?>
postgresql:
image: postgres:18
image: appwrite/postgres:0.1.0
container_name: appwrite-postgresql
restart: unless-stopped
networks:
- appwrite
volumes:
- appwrite-postgresql:/var/lib/postgresql/data:rw
- appwrite-postgresql:/var/lib/postgresql:rw
environment:
- POSTGRES_DB=${_APP_DB_SCHEMA}
- POSTGRES_USER=${_APP_DB_USER}
- POSTGRES_PASSWORD=${_APP_DB_PASS}
command: "postgres"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
<?php endif; ?>
@@ -1088,6 +1144,12 @@ $dbService = $this->getParam('database');
- appwrite
volumes:
- appwrite-redis:/data:rw
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# clamav:
# image: appwrite/clamav:1.2.0
@@ -1114,6 +1176,7 @@ volumes:
<?php elseif ($dbService === 'mongodb'): ?>
appwrite-mongodb:
appwrite-mongodb-keyfile:
appwrite-mongodb-config:
<?php endif; ?>
appwrite-redis:
appwrite-cache:
+5 -1
View File
@@ -3,6 +3,10 @@
$vars = $this->getParam('vars');
foreach ($vars as $key => $value) {
echo $key.'='.$value."\n";
if ($value === null || $value === '') {
echo $key . "=\n";
} else {
echo $key . '="' . addcslashes((string) $value, '"\\$`') . '"' . "\n";
}
}
?>
+145
View File
@@ -0,0 +1,145 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$lockedDatabase = $lockedDatabase ?? null;
$vars = $vars ?? [];
$defaultHttpPort = $defaultHttpPort ?? '80';
$defaultHttpsPort = $defaultHttpsPort ?? '443';
$defaultAppDomain = $vars['_APP_DOMAIN']['default'] ?? 'localhost';
$defaultAppDomain = ($defaultAppDomain === 'traefik') ? 'localhost' : $defaultAppDomain;
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
$defaultDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? 'mongodb';
$lockedDatabase = $isUpgrade && empty($lockedDatabase) ? $defaultDatabase : $lockedDatabase;
$isLocalInstall = $isLocalInstall ?? false;
$cardStep = min(4, $step);
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
if (!is_file($stepFile)) {
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
}
$cssVersion = @filemtime(__DIR__ . '/installer/css/styles.css') ?: time();
$tooltipsVersion = @filemtime(__DIR__ . '/installer/js/tooltips.js') ?: time();
$constantsVersion = @filemtime(__DIR__ . '/installer/js/constants.js') ?: time();
$stepsContextVersion = @filemtime(__DIR__ . '/installer/js/modules/context.js') ?: time();
$stepsStateVersion = @filemtime(__DIR__ . '/installer/js/modules/state.js') ?: time();
$stepsValidationVersion = @filemtime(__DIR__ . '/installer/js/modules/validation.js') ?: time();
$stepsUiVersion = @filemtime(__DIR__ . '/installer/js/modules/ui.js') ?: time();
$stepsToastVersion = @filemtime(__DIR__ . '/installer/js/modules/toast.js') ?: time();
$stepsProgressVersion = @filemtime(__DIR__ . '/installer/js/modules/progress.js') ?: time();
$stepsVersion = @filemtime(__DIR__ . '/installer/js/steps.js') ?: time();
$installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time();
?>
<!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 Update' : 'Appwrite Installation'; ?></title>
<meta name="appwrite-installer-csrf" content="<?php echo htmlspecialchars($csrfToken ?? '', ENT_QUOTES); ?>">
<link rel="icon" type="image/svg+xml" href="installer/icons/appwrite-mark.svg">
<link rel="stylesheet" href="installer/css/styles.css?v=<?php echo $cssVersion; ?>">
<script src="installer/js/tooltips.js?v=<?php echo $tooltipsVersion; ?>" defer></script>
<script src="installer/js/constants.js?v=<?php echo $constantsVersion; ?>" defer></script>
<!-- Load context first; other modules depend on InstallerStepsContext. -->
<script src="installer/js/modules/context.js?v=<?php echo $stepsContextVersion; ?>" defer></script>
<script src="installer/js/modules/state.js?v=<?php echo $stepsStateVersion; ?>" defer></script>
<script src="installer/js/modules/validation.js?v=<?php echo $stepsValidationVersion; ?>" defer></script>
<script src="installer/js/modules/ui.js?v=<?php echo $stepsUiVersion; ?>" defer></script>
<script src="installer/js/modules/toast.js?v=<?php echo $stepsToastVersion; ?>" defer></script>
<script src="installer/js/modules/progress.js?v=<?php echo $stepsProgressVersion; ?>" defer></script>
<script src="installer/js/steps.js?v=<?php echo $stepsVersion; ?>" defer></script>
<script src="installer/js/installer.js?v=<?php echo $installerVersion; ?>" defer></script>
</head>
<body
class="installer-page"
data-step="<?php echo $step; ?>"
data-upgrade="<?php echo $isUpgrade ? 'true' : 'false'; ?>"
<?php if (!empty($lockedDatabase)) { ?>
data-locked-database="<?php echo htmlspecialchars((string) $lockedDatabase, ENT_QUOTES, 'UTF-8'); ?>"
<?php } ?>
data-default-http-port="<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>"
data-default-https-port="<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>"
data-default-app-domain="<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>"
data-default-email-certificates="<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>"
data-default-secret-key=""
data-default-assistant-openai-key=""
data-default-database="<?php echo htmlspecialchars((string) $defaultDatabase, ENT_QUOTES, 'UTF-8'); ?>"
<?php if ($isLocalInstall) { ?>
data-dev-mode="true"
<?php } ?>
>
<div class="installer-backdrop" aria-hidden="true">
<span class="installer-gradients">
<span class="installer-blob blob-one">
<?php include __DIR__ . '/installer/icons/install-bg-1.svg'; ?>
</span>
<span class="installer-blob blob-two">
<?php include __DIR__ . '/installer/icons/install-bg-2.svg'; ?>
</span>
</span>
</div>
<main class="installer-main">
<div class="installer-card" data-step="<?php echo $step; ?>">
<div class="installer-step">
<div class="step-panel is-active">
<?php include $stepFile; ?>
</div>
</div>
<div class="action-shell">
<div class="divider"></div>
<div class="action-bar">
<button class="button secondary" type="button" data-action="back">
<span class="button-text typography-text-m-500">Back</span>
</button>
<div class="step-indicators" aria-hidden="true">
<span class="step-indicator" data-step="1">
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
</span>
<span class="step-indicator" data-step="2">
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
</span>
<span class="step-indicator" data-step="3">
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
</span>
<span class="step-indicator" data-step="4">
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
</span>
</div>
<button class="button primary" type="button" data-action="next">
<span class="button-text typography-text-m-500">Next</span>
</button>
</div>
</div>
</div>
<div class="install-screen" aria-live="polite">
<div class="install-screen-content">
<?php if ($step === 5) { include __DIR__ . '/installer/templates/steps/step-5.phtml'; } ?>
</div>
</div>
</main>
<footer class="installer-footer">
<div class="appwrite-logo">
<?php include __DIR__ . '/installer/icons/appwrite-logo.svg'; ?>
</div>
</footer>
<template id="field-error-template">
<div class="field-error typography-caption-400 text-error">
<span class="field-error-icon">
<?php include __DIR__ . '/installer/icons/exclamation-circle.svg'; ?>
</span>
<span class="field-error-text"></span>
</div>
</template>
<div id="installer-toast-stack" class="installer-toast-stack" aria-live="polite" aria-atomic="true"></div>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
<svg width="131" height="25" viewBox="0 0 131 25" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M38.2643 19.5087C40.4069 19.5087 41.49 18.3947 41.9609 17.6362H42.1728C42.267 18.4421 42.832 19.2006 43.9386 19.2006H46.0341V16.8304H45.4926C45.1159 16.8304 44.9275 16.617 44.9275 16.2852V6.78055H42.1492V8.29751H41.9373C41.3958 7.53903 40.2656 6.47242 38.1937 6.47242C34.8974 6.47242 32.4487 9.2219 32.4487 12.9906C32.4487 16.7593 34.9445 19.5087 38.2643 19.5087ZM38.7588 16.8067C36.8045 16.8067 35.2741 15.3608 35.2741 13.0143C35.2741 10.7151 36.7574 9.15079 38.7352 9.15079C40.6188 9.15079 42.1963 10.5492 42.1963 13.0143C42.1963 15.1238 40.8543 16.8067 38.7588 16.8067Z" fill="#19191D"/>
<path d="M47.6745 24.0166H50.4528V17.6362H50.6647C51.1826 18.3947 52.2893 19.5087 54.4789 19.5087C57.7752 19.5087 60.1768 16.7118 60.1768 12.9906C60.1768 9.2456 57.6104 6.47242 54.2906 6.47242C52.1715 6.47242 51.1356 7.63384 50.6411 8.2738H50.4292V6.78055H47.6745V24.0166ZM53.8903 16.8778C51.9832 16.8778 50.4057 15.4556 50.4057 12.9906C50.4057 10.8811 51.7477 9.10339 53.8432 9.10339C55.7974 9.10339 57.3279 10.644 57.3279 12.9906C57.3279 15.2897 55.8445 16.8778 53.8903 16.8778Z" fill="#19191D"/>
<path d="M61.6104 24.0166H64.3887V17.6362H64.6006C65.1186 18.3947 66.2252 19.5087 68.4149 19.5087C71.7112 19.5087 73.8839 16.7118 73.8839 12.9906C73.8839 9.2456 71.5464 6.47242 68.2265 6.47242C66.1075 6.47242 65.0715 7.63384 64.5771 8.2738H64.3652V6.78055H61.6104V24.0166ZM67.8263 16.8778C65.9191 16.8778 64.3416 15.4556 64.3416 12.9906C64.3416 10.8811 65.6837 9.10339 67.7792 9.10339C69.7334 9.10339 71.2638 10.644 71.2638 12.9906C71.2638 15.2897 69.7805 16.8778 67.8263 16.8778Z" fill="#19191D"/>
<path d="M77.5565 19.489H81.4885L83.7252 9.74733H83.8665L86.1033 19.489H90.0117L93.1415 7.06896H90.3414L88.1046 16.8343H87.8927L85.6559 7.06896H81.9594L79.6991 16.8343H79.4872L77.2739 7.06896H74.3073L77.5565 19.489Z" fill="#19191D"/>
<path d="M94.549 19.489H97.3273V13.3501C97.3273 11.0036 98.4104 9.55771 100.435 9.55771H101.66V6.76083H100.741C99.1638 6.76083 97.963 7.85114 97.4921 8.89405H97.3038V7.06896H94.549V19.489Z" fill="#19191D"/>
<path d="M115.447 19.489H117.613V17.0003H115.47C114.623 17.0003 114.27 16.621 114.27 15.744V9.53401H117.754V7.06896H114.27V3.58472H111.633V7.06896H109.325V9.53401H111.468V15.7677C111.468 18.3987 113.045 19.489 115.447 19.489Z" fill="#19191D"/>
<path d="M125.067 19.5087C127.633 19.5087 129.893 18.2288 130.694 15.6452L128.151 15.029C127.704 16.4037 126.409 17.1148 125.043 17.1148C123.018 17.1148 121.676 15.7875 121.653 13.7016H131V12.9195C131 9.2219 128.716 6.47242 124.949 6.47242C121.629 6.47242 118.78 9.10339 118.78 13.0143C118.78 16.8067 121.3 19.5087 125.067 19.5087ZM121.676 11.6632C121.841 10.17 123.183 8.91377 124.949 8.91377C126.644 8.91377 128.033 9.98037 128.175 11.6632H121.676Z" fill="#19191D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.09 19.489H105.312V9.53401H103.145V7.06896H108.09V19.489Z" fill="#19191D"/>
<path d="M106.494 5.34533C107.507 5.34533 108.26 4.58686 108.26 3.59136C108.26 2.61956 107.507 1.86108 106.494 1.86108C105.482 1.86108 104.729 2.61956 104.729 3.59136C104.729 4.58686 105.482 5.34533 106.494 5.34533Z" fill="#19191D"/>
<path d="M24.2577 16.4436V21.9248H10.6705C6.71194 21.9248 3.25559 19.7204 1.40636 16.4436C1.13754 15.9672 0.90225 15.4674 0.704883 14.9487C0.31744 13.9322 0.0738912 12.8415 0 11.7034V10.2214C0.0160422 9.96781 0.0413207 9.71617 0.0743773 9.46752C0.141949 8.95727 0.244035 8.45799 0.378206 7.97265C1.64748 3.37143 5.77469 0 10.6705 0C15.5662 0 19.693 3.37143 20.9622 7.97265H15.1526C14.1988 6.47279 12.5479 5.4812 10.6705 5.4812C8.79305 5.4812 7.14216 6.47279 6.18839 7.97265C5.89768 8.42859 5.67212 8.93136 5.52434 9.46752C5.39308 9.94289 5.32308 10.4442 5.32308 10.9624C5.32308 12.5335 5.96768 13.9497 7.00119 14.9487C7.95886 15.876 9.25 16.4436 10.6705 16.4436H24.2577Z" fill="#FD366E"/>
<path d="M24.2578 9.46753V14.9487H14.3398C15.3733 13.9497 16.018 12.5335 16.018 10.9624C16.018 10.4442 15.9479 9.9429 15.8167 9.46753H24.2578Z" fill="#FD366E"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,4 @@
<svg width="25" height="22" viewBox="0 0 25 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M24.2577 16.4436V21.9248H10.6705C6.71194 21.9248 3.25559 19.7204 1.40636 16.4436C1.13754 15.9672 0.90225 15.4674 0.704883 14.9487C0.31744 13.9322 0.0738912 12.8415 0 11.7034V10.2214C0.0160422 9.96781 0.0413207 9.71617 0.0743773 9.46752C0.141949 8.95727 0.244035 8.45799 0.378206 7.97265C1.64748 3.37143 5.77469 0 10.6705 0C15.5662 0 19.693 3.37143 20.9622 7.97265H15.1526C14.1988 6.47279 12.5479 5.4812 10.6705 5.4812C8.79305 5.4812 7.14216 6.47279 6.18839 7.97265C5.89768 8.42859 5.67212 8.93136 5.52434 9.46752C5.39308 9.94289 5.32308 10.4442 5.32308 10.9624C5.32308 12.5335 5.96768 13.9497 7.00119 14.9487C7.95886 15.876 9.25 16.4436 10.6705 16.4436H24.2577Z" fill="#FD366E"/>
<path d="M24.2578 9.46753V14.9487H14.3398C15.3733 13.9497 16.018 12.5335 16.018 10.9624C16.018 10.4442 15.9479 9.9429 15.8167 9.46753H24.2578Z" fill="#FD366E"/>
</svg>

After

Width:  |  Height:  |  Size: 980 B

@@ -0,0 +1,3 @@
<svg class="accordion-chevron" data-open="false" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.29289 7.29289C5.68342 6.90237 6.31658 6.90237 6.70711 7.29289L10 10.5858L13.2929 7.29289C13.6834 6.90237 14.3166 6.90237 14.7071 7.29289C15.0976 7.68342 15.0976 8.31658 14.7071 8.70711L10.7071 12.7071C10.3166 13.0976 9.68342 13.0976 9.29289 12.7071L5.29289 8.70711C4.90237 8.31658 4.90237 7.68342 5.29289 7.29289Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 536 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
<path fill="currentColor" d="M7 9a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z"/>
<path fill="currentColor" d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2V5h8a2 2 0 0 0-2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" fill-rule="evenodd" d="M3.707 2.293a1 1 0 1 0-1.414 1.414l14 14a1 1 0 1 0 1.414-1.414l-1.473-1.473A10 10 0 0 0 19.543 10C18.268 5.943 14.478 3 10 3a9.96 9.96 0 0 0-4.512 1.074zm4.261 4.26 1.514 1.515a2.003 2.003 0 0 1 2.45 2.45l1.515 1.514a4 4 0 0 0-5.478-5.478" clip-rule="evenodd"/>
<path fill="currentColor" d="M12.454 16.697 9.75 13.992a4 4 0 0 1-3.742-3.742L2.335 6.578A10 10 0 0 0 .458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.67-.105 2.454-.303"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" d="M10 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/>
<path fill="currentColor" fill-rule="evenodd" d="M.457 10C1.732 5.943 5.522 3 10 3s8.267 2.943 9.542 7c-1.275 4.057-5.065 7-9.542 7-4.478 0-8.268-2.943-9.543-7M14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8Z" fill="#FD366E"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8Z" fill="#D8D8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0M9 9a1 1 0 1 0 0 2v3a1 1 0 0 0 1 1h1a1 1 0 1 0 0-2v-3a1 1 0 0 0-1-1z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="408" height="353" viewBox="0 0 408 353" fill="none" aria-hidden="true">
<g filter="url(#filter0_f_3091_1065)">
<path d="M216.4 88.698C162.554 64.5014 104.764 118.126 82.6001 147.963C90.6227 156.819 114.663 172.323 146.645 163.495C186.622 152.459 191.109 162.269 229.454 181.887C267.799 201.506 268.207 261.18 291.051 268.537C313.895 275.894 322.054 244.422 324.501 193.74C326.949 143.058 283.709 118.944 216.4 88.698Z" fill="url(#paint0_radial_3091_1065)"/>
</g>
<defs>
<filter id="filter0_f_3091_1065" x="9.91821e-05" y="-2.28882e-05" width="407.2" height="352.2" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="41.3" result="effect1_foregroundBlur_3091_1065"/>
</filter>
<radialGradient id="paint0_radial_3091_1065" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(203.6 176.1) rotate(-90) scale(93.5 121)">
<stop stop-color="#FE9567"/>
<stop offset="1" stop-color="#FE9567" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,16 @@
<svg width="569" height="367" viewBox="0 0 569 367" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g filter="url(#install-bg-2-filter)">
<path d="M136.265 227.973C89.0703 207.713 99.6672 144.647 110.865 115.646C130.119 111.289 176.904 102.33 210.005 101.35C251.382 100.125 288.253 121.773 333.317 135.252C378.381 148.731 466.051 125.449 467.28 160.577C468.509 195.704 412.793 254.522 402.142 242.269C391.49 230.015 319.798 227.973 246.057 254.522C172.316 281.072 195.257 253.297 136.265 227.973Z" fill="url(#install-bg-2-paint)"/>
</g>
<defs>
<filter id="install-bg-2-filter" x="-0.000198364" y="0.0000457764" width="568.6" height="366.6" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="50.65" result="effect1_foregroundBlur_install_bg_2"/>
</filter>
<radialGradient id="install-bg-2-paint" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(284.3 183.3) rotate(-90) scale(82 183)">
<stop stop-color="#FD366E"/>
<stop offset="1" stop-color="#FD366E" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle class="check-bg" cx="8" cy="8" r="7" fill="var(--fgcolor-neutral-primary)"/>
<path class="check-mark" d="M5 8.2L7.2 10.2L11.2 6.2" fill="none" stroke="var(--fgcolor-on-invert)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path class="spinner-ring" opacity="0.2" d="M14.4001 7.99998C14.4001 11.5346 11.5347 14.4 8.0001 14.4C4.46548 14.4 1.6001 11.5346 1.6001 7.99998C1.6001 4.46535 4.46548 1.59998 8.0001 1.59998C11.5347 1.59998 14.4001 4.46535 14.4001 7.99998ZM2.8801 7.99998C2.8801 10.8277 5.1724 13.12 8.0001 13.12C10.8278 13.12 13.1201 10.8277 13.1201 7.99998C13.1201 5.17228 10.8278 2.87998 8.0001 2.87998C5.1724 2.87998 2.8801 5.17228 2.8801 7.99998Z" fill="var(--fgcolor-neutral-secondary)"/>
<path class="spinner-arc" d="M4.32921 2.7574C5.15654 2.1781 6.10923 1.80282 7.10939 1.66226C8.10955 1.5217 9.12877 1.61984 10.0837 1.94866C11.0387 2.27748 11.9023 2.82764 12.6039 3.55416C13.3055 4.28069 13.8252 5.16294 14.1204 6.1288C14.4157 7.09465 14.4782 8.11668 14.3029 9.11133C14.1275 10.106 13.7192 11.045 13.1114 11.8516C12.5035 12.6582 11.7134 13.3095 10.8057 13.7523C9.8979 14.195 8.89823 14.4166 7.8884 14.399L7.91074 13.1192C8.71861 13.1333 9.51834 12.956 10.2446 12.6018C10.9708 12.2476 11.6029 11.7266 12.0891 11.0813C12.5754 10.436 12.902 9.68477 13.0423 8.88906C13.1826 8.09334 13.1326 7.27572 12.8964 6.50303C12.6601 5.73035 12.2444 5.02454 11.6831 4.44333C11.1218 3.86211 10.431 3.42198 9.66701 3.15892C8.90304 2.89586 8.08766 2.81735 7.28753 2.9298C6.48741 3.04225 5.72525 3.34247 5.06339 3.80592L4.32921 2.7574Z" fill="var(--fgcolor-neutral-secondary)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M5 9V7a5 5 0 1 1 10 0v2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m8-2v2H7V7a3 3 0 1 1 6 0" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,10 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="33" height="33" rx="8.5" fill="white" stroke="#EDEDF0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.3208 9.43304C27.9548 9.4451 28.0607 9.61 27.2684 9.80574C26.468 10.0028 25.5 9.88216 24.6393 10.2441C22.387 11.188 22.0224 14.8372 19.3397 16.249C17.5807 17.2424 15.7869 17.4703 14.1835 17.973C12.8965 18.4892 12.0961 18.8311 11.1523 19.6448C10.4203 20.2763 10.242 20.889 9.47647 21.6813C8.70022 22.7364 5.75747 21.7993 5 23.1225C5.39952 23.3813 5.6301 23.4523 6.33127 23.3598C6.18647 23.6346 5.26411 23.9966 5.43169 24.4015C5.43169 24.4015 7.66121 24.8077 9.54081 23.6735C10.4176 23.3169 11.2394 22.5621 12.6015 22.3811C14.3658 22.1465 16.354 22.7512 18.487 22.9214C18.046 23.7928 17.5915 24.3077 17.1048 25.0263C16.9547 25.1885 17.2335 25.3319 17.751 25.2341C18.6814 25.0035 19.3558 24.7541 20.0248 24.2916C20.8975 23.6896 21.2729 23.1386 22.009 22.2658C22.6484 23.2914 24.9034 23.5167 25.366 22.6305C24.5053 22.2658 24.3216 20.3688 24.6165 19.5497C24.9651 18.7694 25.2158 17.666 25.4974 16.6404C25.7507 15.7167 25.9076 14.3077 26.2106 13.5864C26.5726 12.6895 27.2764 12.4093 27.8046 11.9334C28.3328 11.4574 28.8584 11.0606 28.8423 9.97198C28.8369 9.61805 28.6546 9.42231 28.3208 9.43304Z" fill="#1F305F"/>
<path d="M5.673 24.2137C7.05522 24.2553 7.43061 24.2191 8.52324 23.7445C9.45232 23.341 10.6951 22.2537 11.7931 21.9024C13.4046 21.3849 15.1327 21.4627 16.8474 21.6651C17.4212 21.7335 17.9977 21.8287 18.4093 21.7844C19.0514 21.3903 19.0823 20.3204 19.4805 20.2346C19.3719 22.3046 18.4843 23.6292 17.5968 24.8304C19.467 24.5006 20.7165 23.3329 21.4727 21.8877C21.7019 21.4493 22.0827 20.732 22.257 20.2387C22.395 20.5658 22.0773 20.7736 22.2261 21.1302C23.43 20.1368 23.9985 19.0053 24.4865 17.3482C25.0522 15.4311 25.6327 13.7834 25.9974 13.2163C26.3527 12.6626 26.9064 12.3207 27.4118 11.9668C27.9856 11.5633 28.4991 11.1436 28.5875 10.3754C27.9816 10.3191 27.8421 10.1797 27.7523 9.87402C27.4493 10.0443 27.1705 10.0818 26.8554 10.0912C26.5819 10.0993 26.2816 10.0872 25.9143 10.1247C22.8804 10.4358 22.7396 13.4013 20.5342 15.5785C20.3921 15.7166 20.1494 15.9204 19.9886 16.0411C19.3075 16.5492 18.5782 16.828 17.81 17.1216C16.5658 17.5962 15.3861 17.7343 14.2197 18.1446C13.363 18.4449 12.5667 18.7894 11.8628 19.2707C11.6872 19.3914 11.4486 19.6059 11.2957 19.7306C10.8828 20.0684 10.612 20.4424 10.3492 20.8286C10.0784 21.2254 9.81831 21.6343 9.42014 22.0244C8.77528 22.6572 6.36746 22.2094 5.51882 22.7966C5.42497 22.8623 5.34855 22.9401 5.29761 23.0339C5.76014 23.2444 6.06982 23.1157 6.60207 23.1734C6.66776 23.6815 5.50005 23.9818 5.673 24.2137Z" fill="#C0765A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.9033 11.3797C25.3323 11.7524 26.2333 11.4534 26.0724 10.712C25.4047 10.6557 25.0186 10.8823 24.9033 11.3797Z" fill="#1F305F"/>
<path d="M27.8971 10.5123C27.7831 10.7523 27.5646 11.0606 27.5646 11.6706C27.5632 11.7752 27.4855 11.8476 27.4841 11.6854C27.4895 11.0888 27.6477 10.8314 27.8153 10.4935C27.8917 10.3554 27.9386 10.4117 27.8971 10.5123Z" fill="#1F305F"/>
<path d="M27.7818 10.4225C27.6464 10.6504 27.322 11.0674 27.2697 11.676C27.2603 11.7806 27.1745 11.845 27.1879 11.6841C27.2469 11.0915 27.5057 10.7202 27.7028 10.3971C27.7912 10.2657 27.8328 10.326 27.7818 10.4225Z" fill="#1F305F"/>
<path d="M27.6772 10.303C27.523 10.5189 27.0243 11.0203 26.9197 11.6209C26.901 11.7241 26.8112 11.7804 26.838 11.6222C26.9466 11.0364 27.3769 10.5752 27.5995 10.2708C27.6987 10.1462 27.7362 10.2105 27.6772 10.303Z" fill="#1F305F"/>
<path d="M27.5835 10.1688C27.4012 10.3619 26.8059 11 26.6182 11.5805C26.5847 11.6797 26.4882 11.724 26.5365 11.5698C26.7255 11.0041 27.247 10.3954 27.5111 10.1259C27.6278 10.0173 27.6546 10.087 27.5835 10.1688Z" fill="#1F305F"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="#081E2B"/>
<g transform="translate(11.4, 6.1)">
<path d="M6.03701 2.19958C5.24603 1.21687 4.56489 0.218806 4.42574 0.0115161C4.41109 -0.00383871 4.38911 -0.00383871 4.37447 0.0115161C4.23531 0.218806 3.55418 1.21687 2.76319 2.19958C-4.02614 11.2666 3.8325 17.3856 3.8325 17.3856L3.89841 17.4316C3.95699 18.3759 4.10348 19.7348 4.10348 19.7348H4.39644H4.6894C4.6894 19.7348 4.83588 18.3836 4.89447 17.4316L4.96038 17.3779C4.9677 17.3779 12.8264 11.2666 6.03701 2.19958ZM4.39644 17.2473C4.39644 17.2473 4.04489 16.9325 3.94968 16.7713V16.756L4.37447 6.88281C4.37447 6.8521 4.41842 6.8521 4.41842 6.88281L4.8432 16.756V16.7713C4.74799 16.9325 4.39644 17.2473 4.39644 17.2473Z" fill="#00ED64"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 861 B

@@ -0,0 +1,27 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="31" height="31" rx="7.5" fill="white" stroke="#EDEDF0"/>
<g transform="translate(3.2, 3.2) scale(1)">
<svg viewBox="0 0 25.6 25.6" width="25.6" height="25.6">
<style><![CDATA[.B{stroke-linecap:round}.C{stroke-linejoin:round}.D{stroke-linejoin:miter}.E{stroke-width:.716}]]></style>
<g fill="none" stroke="#fff">
<path d="M18.983 18.636c.163-1.357.114-1.555 1.124-1.336l.257.023c.777.035 1.793-.125 2.4-.402 1.285-.596 2.047-1.592.78-1.33-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.227-11.687-3.004-3.84-8.205-2.024-8.292-1.976l-.028.005c-.57-.12-1.2-.19-1.93-.2-1.308-.02-2.3.343-3.054.914 0 0-9.277-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.01 2.01 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.285 1.76.33 2.842s.116 2.093.337 2.688.48 2.13 2.53 1.7c1.713-.367 3.023-.896 3.143-5.81" fill="#000" stroke="#000" stroke-linecap="butt" stroke-width="2.149" class="D"/>
<path d="M23.535 15.6c-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.228-11.687-3.004-3.84-8.205-2.023-8.292-1.976l-.028.005a10.31 10.31 0 0 0-1.929-.201c-1.308-.02-2.3.343-3.054.914 0 0-9.278-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.02 2.02 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.52 1.593.484 2.815s-.06 2.06.18 2.716.48 2.13 2.53 1.7c1.713-.367 2.6-1.32 2.725-2.906.088-1.128.286-.962.3-1.97l.16-.478c.183-1.53.03-2.023 1.085-1.793l.257.023c.777.035 1.794-.125 2.39-.402 1.285-.596 2.047-1.592.78-1.33z" fill="#336791" stroke="none"/>
<g class="E">
<g class="B">
<path d="M12.814 16.467c-.08 2.846.02 5.712.298 6.4s.875 2.05 2.926 1.612c1.713-.367 2.337-1.078 2.607-2.647l.633-5.017M10.356 2.2S1.072-1.596 1.504 7.033c.092 1.836 2.63 13.9 5.66 10.25C8.27 15.95 9.27 14.907 9.27 14.907m6.1-13.4c-.32.1 5.164-2.005 8.282 1.978 1.1 1.407-.175 7.157-3.228 11.687" class="C"/>
<path d="M20.425 15.17s.2.98 3.1.382c1.267-.262.504.734-.78 1.33-1.054.49-3.418.615-3.457-.06-.1-1.745 1.244-1.215 1.147-1.652-.088-.394-.69-.78-1.086-1.744-.347-.84-4.76-7.29 1.224-6.333.22-.045-1.56-5.7-7.16-5.782S7.99 8.196 7.99 8.196" stroke-linejoin="bevel"/>
</g>
<g class="C">
<path d="M11.247 15.768c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.163.35-.49-.002-1.27-.482-1.468-.232-.096-.542-.216-.94.23z"/>
<path d="M11.196 15.753c-.08-.513.168-1.122.433-1.836.398-1.07 1.316-2.14.582-5.537-.547-2.53-4.22-.527-4.22-.184s.166 1.74-.06 3.365c-.297 2.122 1.35 3.916 3.246 3.733" class="B"/>
</g>
</g>
<g fill="#fff" class="D">
<path d="M10.322 8.145c-.017.117.215.43.516.472s.558-.202.575-.32-.215-.246-.516-.288-.56.02-.575.136z" stroke-width=".239"/>
<path d="M19.486 7.906c.016.117-.215.43-.516.472s-.56-.202-.575-.32.215-.246.516-.288.56.02.575.136z" stroke-width=".119"/>
</g>
<path d="M20.562 7.095c.05.92-.198 1.545-.23 2.524-.046 1.422.678 3.05-.413 4.68" class="B C E"/>
</g>
</svg>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.99976 2C4.55204 2 4.99976 2.44772 4.99976 3V5.10125C6.26985 3.80489 8.04028 3 9.99976 3C13.0492 3 15.6407 4.94932 16.6012 7.66675C16.7852 8.18747 16.5123 8.75879 15.9916 8.94284C15.4709 9.12689 14.8996 8.85396 14.7155 8.33325C14.0286 6.38991 12.1752 5 9.99976 5C8.36482 5 6.91179 5.78502 5.99911 7H8.99976C9.55204 7 9.99976 7.44771 9.99976 8C9.99976 8.55228 9.55204 9 8.99976 9H3.99976C3.44747 9 2.99976 8.55228 2.99976 8V3C2.99976 2.44772 3.44747 2 3.99976 2ZM4.00792 11.0572C4.52864 10.8731 5.09996 11.146 5.28401 11.6668C5.97088 13.6101 7.82429 15 9.99976 15C11.6347 15 13.0877 14.215 14.0004 13L10.9998 13C10.4475 13 9.99976 12.5523 9.99976 12C9.99976 11.4477 10.4475 11 10.9998 11H15.9998C16.265 11 16.5193 11.1054 16.7069 11.2929C16.8944 11.4804 16.9998 11.7348 16.9998 12V17C16.9998 17.5523 16.552 18 15.9998 18C15.4475 18 14.9998 17.5523 14.9998 17V14.8987C13.7297 16.1951 11.9592 17 9.99976 17C6.95035 17 4.3588 15.0507 3.39833 12.3332C3.21428 11.8125 3.48721 11.2412 4.00792 11.0572Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="3" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 157 B

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.25706 3.09882C9.02167 1.73952 10.9788 1.73952 11.7434 3.09882L17.3237 13.0194C18.0736 14.3526 17.1102 15.9999 15.5805 15.9999H4.4199C2.89025 15.9999 1.92682 14.3526 2.67675 13.0194L8.25706 3.09882ZM11.0001 13C11.0001 13.5523 10.5524 14 10.0001 14C9.44784 14 9.00012 13.5523 9.00012 13C9.00012 12.4477 9.44784 12 10.0001 12C10.5524 12 11.0001 12.4477 11.0001 13ZM10.0001 5C9.44784 5 9.00012 5.44772 9.00012 6V9C9.00012 9.55228 9.44784 10 10.0001 10C10.5524 10 11.0001 9.55228 11.0001 9V6C11.0001 5.44772 10.5524 5 10.0001 5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 725 B

@@ -0,0 +1,11 @@
(() => {
window.InstallerConstants = Object.freeze({
stepTransitionMs: 260,
errorClearMs: 180,
installPollIntervalMs: 4000,
installFallbackDelayMs: 12000,
redirectDelayMs: 2500,
progressTransitionDelayMs: 320,
progressCompleteDelayMs: 140,
});
})();
+450
View File
@@ -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();
}
});
})();
@@ -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
});
})();
@@ -0,0 +1,899 @@
(() => {
const {
INSTALLATION_STEPS,
TIMINGS,
getBodyDataset,
isUpgradeMode,
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 = `${host}:${port}`;
}
return `${protocol}://${host}`;
};
const redirectToApp = () => {
const url = buildRedirectUrl();
if (!url) return;
// Fire-and-forget: tell the installer server it can shut down
fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {});
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 && !isUpgradeMode?.()) {
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();
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;
}
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);
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) {
progressState.set(previousStep.id, {
status: STATUS.COMPLETED,
message: previousStep.done,
details: previousState.details
});
}
}
}
}
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 checkAllCompleted = () => {
const allDone = INSTALLATION_STEPS.every((step) => {
const state = progressState.get(step.id);
return state && state.status === STATUS.COMPLETED;
});
if (!allDone) return;
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const sessionDetails = accountState?.details;
finalizeInstall();
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
});
};
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);
checkAllCompleted();
}
}, 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) {
// Mark every step as completed (preserving details
// from earlier progress events, e.g. session info).
INSTALLATION_STEPS.forEach((step) => {
const existing = progressState.get(step.id);
if (!existing || (existing.status !== STATUS.COMPLETED && existing.status !== STATUS.ERROR)) {
progressState.set(step.id, {
status: STATUS.COMPLETED,
message: step.done,
details: existing?.details
});
}
});
renderProgress();
// If any step ended in error (e.g. account creation
// failed), stay on the progress screen so the user can
// see the error and choose to retry or navigate to the
// console manually — don't auto-redirect.
const hasErrors = INSTALLATION_STEPS.some((step) => {
const state = progressState.get(step.id);
return state && state.status === STATUS.ERROR;
});
if (hasErrors) {
finalizeInstall();
return;
}
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const sessionDetails = accountState?.details;
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) {
// Stream ended without a "done" event (e.g. browser
// throttled the background tab). Check if we're done.
checkAllCompleted();
if (!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);
}
});
// When the user switches back to this tab, check if installation
// finished while the tab was in the background.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && activeInstall && !activeInstall.completed) {
checkAllCompleted();
}
});
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
};
})();
@@ -0,0 +1,158 @@
(() => {
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 sanitizedPayload = payload ? { ...payload } : null;
if (sanitizedPayload) {
delete sanitizedPayload.opensslKey;
delete sanitizedPayload.accountPassword;
delete sanitizedPayload.assistantOpenAIKey;
}
const lock = {
installId,
payload: sanitizedPayload,
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('accountEmail', payload.accountEmail);
};
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 || (() => '')
};
})();
@@ -0,0 +1,95 @@
(() => {
const TOAST_STACK_ID = 'installer-toast-stack';
const DEFAULT_TIMEOUT = 5000;
const MAX_TOASTS = 3;
const ICONS = {
error: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" clip-rule="evenodd"/></svg>',
close: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M5.293 5.293a1 1 0 0 1 1.414 0L10 8.586l3.293-3.293a1 1 0 1 1 1.414 1.414L11.414 10l3.293 3.293a1 1 0 0 1-1.414 1.414L10 11.414l-3.293 3.293a1 1 0 0 1-1.414-1.414L8.586 10 5.293 6.707a1 1 0 0 1 0-1.414" clip-rule="evenodd"/></svg>'
};
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
});
})();
@@ -0,0 +1,281 @@
(() => {
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 '';
const lower = value.toLowerCase();
if (lower === 'mariadb') return 'MariaDB';
if (lower === 'postgresql') return 'PostgreSQL';
return '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
};
})();
@@ -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
};
})();
+435
View File
@@ -0,0 +1,435 @@
(() => {
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,
isUpgradeMode
} = 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 (!isUpgradeMode?.() && (!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;
}
};
})();
@@ -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
};
})();
@@ -0,0 +1,184 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$lockedDatabase = $lockedDatabase ?? null;
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
$defaultHttpPort = $defaultHttpPort ?? '80';
$defaultHttpsPort = $defaultHttpsPort ?? '443';
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
$defaultAssistantOpenAIKey = $defaultAssistantOpenAIKey ?? '';
$defaultDatabase = $defaultDatabase ?? 'mongodb';
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
$isDatabaseLocked = !empty($lockedDatabase);
$mongoDisabled = $isDatabaseLocked && $selectedDatabase !== 'mongodb';
$mariaDisabled = $isDatabaseLocked && $selectedDatabase !== 'mariadb';
$postgresDisabled = $isDatabaseLocked && $selectedDatabase !== 'postgresql';
$hostnameValue = htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8');
$httpPortValue = htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8');
$httpsPortValue = htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8');
$sslEmailValue = htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8');
$assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey, ENT_QUOTES, 'UTF-8');
?>
<div class="step-layout" data-step="1">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Update your app' : 'Setup your app'; ?></h1>
<p class="typography-text-m-400 text-neutral-secondary">
<?php echo $isUpgrade ? 'Review your current settings before updating.' : 'Choose where your app will live and how it will store data'; ?>
</p>
</div>
<div class="stack-xl">
<div class="input-group stack-xs">
<label for="hostname" class="label-text typography-text-m-500 text-neutral-secondary">Hostname</label>
<input
type="text"
id="hostname"
name="hostname"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="localhost"
value="<?php echo $hostnameValue; ?>"
>
</div>
<div class="input-group stack-xs">
<label class="label-text typography-text-m-500 text-neutral-secondary">Database</label>
<div class="selector-group<?php echo $isDatabaseLocked ? ' is-locked' : ''; ?>">
<label class="selector-card <?php echo ($selectedDatabase === 'mongodb') ? 'selected' : ''; ?><?php echo $mongoDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $mongoDisabled ? ' aria-disabled="true"' : ''; ?>>
<input type="radio" name="database" value="mongodb" <?php echo ($selectedDatabase === 'mongodb') ? 'checked' : ''; ?> class="sr-only" <?php echo $mongoDisabled ? 'disabled' : ''; ?>>
<div class="selector-content">
<div class="typography-text-m-500 text-neutral-primary">MongoDB</div>
<div class="typography-text-xs-400 text-neutral-secondary">Document based database</div>
</div>
<img src="installer/icons/mongodb.svg" alt="MongoDB" class="selector-icon">
<?php if ($mongoDisabled) { ?>
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
<?php } ?>
</label>
<label class="selector-card <?php echo ($selectedDatabase === 'mariadb') ? 'selected' : ''; ?><?php echo $mariaDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $mariaDisabled ? ' aria-disabled="true"' : ''; ?>>
<input type="radio" name="database" value="mariadb" <?php echo ($selectedDatabase === 'mariadb') ? 'checked' : ''; ?> class="sr-only" <?php echo $mariaDisabled ? 'disabled' : ''; ?>>
<div class="selector-content">
<div class="typography-text-m-500 text-neutral-primary">MariaDB</div>
<div class="typography-text-xs-400 text-neutral-secondary">Relational SQL database</div>
</div>
<img src="installer/icons/mariadb.svg" alt="MariaDB" class="selector-icon">
<?php if ($mariaDisabled) { ?>
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
<?php } ?>
</label>
<label class="selector-card <?php echo ($selectedDatabase === 'postgresql') ? 'selected' : ''; ?><?php echo $postgresDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $postgresDisabled ? ' aria-disabled="true"' : ''; ?>>
<input type="radio" name="database" value="postgresql" <?php echo ($selectedDatabase === 'postgresql') ? 'checked' : ''; ?> class="sr-only" <?php echo $postgresDisabled ? 'disabled' : ''; ?>>
<div class="selector-content">
<div class="typography-text-m-500 text-neutral-primary">PostgreSQL</div>
<div class="typography-text-xs-400 text-neutral-secondary">Relational SQL database</div>
</div>
<img src="installer/icons/postgresql.svg" alt="PostgreSQL" class="selector-icon">
<?php if ($postgresDisabled) { ?>
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
<?php } ?>
</label>
</div>
</div>
<div class="accordion">
<button
type="button"
class="accordion-toggle"
aria-expanded="false"
>
<span class="label-text typography-text-m-500 text-neutral-secondary">Advanced settings</span>
<span class="accordion-chevron" data-open="false">
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
</span>
</button>
<div class="accordion-content">
<div class="input-row">
<div class="input-group stack-xs">
<label for="http-port" class="label-text typography-text-m-500 text-neutral-secondary">HTTP port</label>
<input
type="number"
id="http-port"
name="httpPort"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="<?php echo $httpPortValue; ?>"
value="<?php echo $httpPortValue; ?>"
data-default="<?php echo $httpPortValue; ?>"
min="1"
max="65535"
step="1"
>
</div>
<button type="button" class="button secondary" data-reset-target="http-port" disabled>
<span class="button-text typography-text-m-500">Reset</span>
</button>
</div>
<div class="input-row">
<div class="input-group stack-xs">
<label for="https-port" class="label-text typography-text-m-500 text-neutral-secondary">HTTPS port</label>
<input
type="number"
id="https-port"
name="httpsPort"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="<?php echo $httpsPortValue; ?>"
value="<?php echo $httpsPortValue; ?>"
data-default="<?php echo $httpsPortValue; ?>"
min="1"
max="65535"
step="1"
>
</div>
<button type="button" class="button secondary" data-reset-target="https-port" disabled>
<span class="button-text typography-text-m-500">Reset</span>
</button>
</div>
<div class="input-row">
<div class="input-group stack-xs">
<label for="ssl-email" class="label-text typography-text-m-500 text-neutral-secondary">SSL certificate email</label>
<input
type="email"
id="ssl-email"
name="sslEmail"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="you@example.com"
value="<?php echo $sslEmailValue; ?>"
data-default="<?php echo $sslEmailValue; ?>"
>
</div>
<button type="button" class="button secondary" data-reset-target="ssl-email" disabled>
<span class="button-text typography-text-m-500">Reset</span>
</button>
</div>
<div class="input-row">
<div class="input-group stack-xs">
<label for="assistant-openai-key" class="label-text typography-text-m-500 text-neutral-secondary">
OpenAI key
<span class="label-optional typography-text-m-400 text-neutral-tertiary">optional</span>
<span class="tooltip-wrapper">
<button type="button" class="label-info-button" aria-label="OpenAI key info">
<?php include __DIR__ . '/../../icons/info.svg'; ?>
</button>
<span class="tooltip tooltip-assistant typography-text-m-400 text-on-invert" role="tooltip">Add your OpenAI key to enable Appwrite Assistant. The key is stored locally and wont be shared.</span>
</span>
</label>
<input
type="text"
id="assistant-openai-key"
name="assistantOpenAIKey"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="Enter OpenAI key for Appwrite Assistant"
value="<?php echo $assistantOpenAIKeyValue; ?>"
data-default="<?php echo $assistantOpenAIKeyValue; ?>"
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,58 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$defaultSecretKey = $defaultSecretKey ?? '';
$secretKeyValue = htmlspecialchars((string) $defaultSecretKey, ENT_QUOTES, 'UTF-8');
?>
<div class="step-layout" data-step="2">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary">Secure your app</h1>
<p class="typography-text-m-400 text-neutral-secondary">
<?php echo $isUpgrade ? 'Review your existing secret key or generate a new one.' : 'Your server\'s private secret key for encryption. Generate a random secure key, or provide your own.'; ?>
</p>
</div>
<div class="stack-xl">
<div class="inline-alert inline-alert--warning">
<div class="inline-alert-content">
<div class="inline-alert-icon" aria-hidden="true">
<?php include __DIR__ . '/../../icons/warning.svg'; ?>
</div>
<div class="inline-alert-text">
<div class="inline-alert-title typography-text-m-500 text-warning">Save your key</div>
<div class="inline-alert-description typography-text-m-400 text-neutral-primary">You won't be able to see this key again. Copy it somewhere safe before continuing.</div>
</div>
</div>
</div>
<div class="input-row">
<div class="input-group stack-xs">
<label for="secret-key" class="label-text typography-text-m-500 text-neutral-secondary">Secret API key</label>
<div class="input-action">
<input
type="text"
id="secret-key"
name="secretKey"
class="input-action-input typography-text-m-400 text-neutral-primary"
value="<?php echo $secretKeyValue; ?>"
minlength="1"
maxlength="64"
>
<div class="input-action-buttons">
<span class="tooltip-wrapper" data-tooltip-default="Copy" data-tooltip-success="Copied">
<button type="button" class="input-icon-button" aria-label="Copy secret key" aria-describedby="copy-tooltip" data-copy-target="secret-key">
<?php include __DIR__ . '/../../icons/copy.svg'; ?>
</button>
<span id="copy-tooltip" class="tooltip typography-text-m-400 text-on-invert" role="tooltip">Copy</span>
</span>
</div>
</div>
</div>
<button type="button" class="icon-button" aria-label="Regenerate secret key" data-regenerate-target="secret-key">
<?php include __DIR__ . '/../../icons/refresh.svg'; ?>
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,61 @@
<?php
$defaultAccountEmail = $defaultAccountEmail ?? '';
$accountEmailValue = htmlspecialchars($defaultAccountEmail, ENT_QUOTES, 'UTF-8');
?>
<div class="step-layout" data-step="3">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary">Create your account</h1>
<p class="typography-text-m-400 text-neutral-secondary">
Set up the email and password for your Appwrite account. You can use these
credentials to sign in later.
</p>
</div>
<div class="stack-l">
<div class="input-group stack-xs">
<label for="account-email" class="label-text typography-text-m-500 text-neutral-secondary">Email</label>
<input
type="email"
id="account-email"
name="accountEmail"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="you@example.com"
value="<?php echo $accountEmailValue; ?>"
autocomplete="email"
>
</div>
<div class="input-group stack-xs">
<label for="account-password"
class="label-text typography-text-m-500 text-neutral-secondary">Password</label>
<div class="input-action">
<input
type="password"
id="account-password"
name="accountPassword"
class="input-action-input typography-text-m-400 text-neutral-primary"
placeholder="Enter password"
autocomplete="new-password"
>
<div class="input-action-buttons">
<button type="button" class="input-icon-button password-toggle" aria-label="Show password" data-password-toggle="account-password">
<span class="password-toggle-icon" data-password-icon="hide">
<?php include __DIR__ . '/../../icons/eye-off.svg'; ?>
</span>
<span class="password-toggle-icon" data-password-icon="show">
<?php include __DIR__ . '/../../icons/eye.svg'; ?>
</span>
</button>
</div>
</div>
<div class="field-helper typography-caption-400 text-neutral-secondary">
<span class="field-helper-icon">
<?php include __DIR__ . '/../../icons/info.svg'; ?>
</span>
<span class="field-helper-text">Password must be at least 8 characters long</span>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,76 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$lockedDatabase = $lockedDatabase ?? null;
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
$defaultHttpPort = $defaultHttpPort ?? '80';
$defaultHttpsPort = $defaultHttpsPort ?? '443';
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
$defaultSecretKey = $defaultSecretKey ?? '';
$defaultDatabase = $defaultDatabase ?? 'mongodb';
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
$defaultDatabaseLabel = match ($selectedDatabase) {
'mariadb' => 'MariaDB',
'postgresql' => 'PostgreSQL',
default => 'MongoDB',
};
$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing';
$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
?>
<div class="step-layout" data-step="4">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Review your update' : 'Review your setup'; ?></h1>
<p class="typography-text-m-400 text-neutral-secondary">
<?php echo $isUpgrade ? 'Confirm your settings before updating Appwrite.' : 'Check your settings below before completing the installation.'; ?>
</p>
</div>
<div class="stack-xl">
<div class="review-card">
<div class="stack-s">
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="appDomain">
<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Hostname</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="database">
<?php echo htmlspecialchars((string) $defaultDatabaseLabel, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Database</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpPort">
<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTP port</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpsPort">
<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTPS port</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="emailCertificates">
<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">SSL certificate email</div>
</div>
<div class="review-row">
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
</div>
<div class="review-row">
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,53 @@
<?php
$isUpgrade = $isUpgrade ?? false;
?>
<div class="step-layout install-layout" data-step="5">
<div class="install-card">
<div class="install-panel">
<div class="install-header">
<div class="typography-text-m-400 text-neutral-primary">
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
</div>
</div>
<div class="install-list" data-install-list></div>
</div>
</div>
<template id="install-row-template">
<div class="install-row" data-status="in-progress">
<div class="install-row-main">
<div class="install-row-label">
<span class="install-icon" aria-hidden="true">
<span class="install-icon-spinner">
<?php include __DIR__ . '/../../icons/install-spinner.svg'; ?>
</span>
<span class="install-icon-check">
<?php include __DIR__ . '/../../icons/install-check.svg'; ?>
</span>
<span class="install-icon-error">
<?php include __DIR__ . '/../../icons/exclamation-circle.svg'; ?>
</span>
</span>
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
</div>
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
</button>
</div>
<div class="install-row-details">
<div class="install-row-details-inner">
<pre class="install-error-code typography-text-xs-400" data-install-trace></pre>
<div class="install-error-actions">
<button type="button" class="button secondary" data-install-retry>
<span class="button-text typography-text-m-500">Retry</span>
</button>
<button type="button" class="button primary is-hidden" data-install-console>
<span class="button-text typography-text-m-500">Navigate to Console</span>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
+28 -26
View File
@@ -13,11 +13,12 @@ use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Platform\Appwrite;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Swoole\Runtime;
@@ -42,6 +43,7 @@ use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Message;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Queue\Server;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
@@ -58,7 +60,8 @@ Server::setResource('register', fn () => $register);
Server::setResource('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
return $authorization;
}, []);
Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) {
@@ -70,9 +73,7 @@ Server::setResource('dbForPlatform', function (Cache $cache, Registry $register,
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setNamespace('_console')
->setDocumentType('users', User::class)
;
->setDocumentType('users', User::class);
return $dbForPlatform;
}, ['cache', 'register', 'authorization']);
@@ -111,7 +112,7 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register,
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int)$project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -151,7 +152,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int)$project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -173,7 +174,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant((int)$project->getSequence())
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -193,9 +194,10 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf
Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant((int)$project->getSequence());
$database->setTenant($project->getSequence());
return $database;
}
@@ -210,9 +212,8 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
// set tenant
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant((int)$project->getSequence());
$database->setTenant($project->getSequence());
}
return $database;
@@ -227,6 +228,7 @@ Server::setResource('auditRetention', function (Document $project) {
if ($project->getId() === 'console') {
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
}
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
}, ['project']);
@@ -252,7 +254,7 @@ Server::setResource('redis', function () {
$pass = System::getEnv('_APP_REDIS_PASS', '');
$redis = new \Redis();
@$redis->pconnect($host, (int)$port);
@$redis->pconnect($host, (int) $port);
if ($pass) {
$redis->auth($pass);
}
@@ -269,7 +271,6 @@ Server::setResource('timelimit', function (\Redis $redis) {
Server::setResource('log', fn () => new Log());
Server::setResource('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
@@ -286,10 +287,6 @@ Server::setResource('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherStatsUsage', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
@@ -310,9 +307,13 @@ Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('queueForStatsUsage', function (Publisher $publisher) {
return new StatsUsage($publisher);
}, ['publisher']);
Server::setResource('usage', function () {
return new Context();
}, []);
Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
Server::setResource('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($publisher);
@@ -354,7 +355,6 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Server::setResource('queueForRealtime', function () {
return new Realtime();
}, []);
@@ -484,11 +484,13 @@ Server::setResource('getAudit', function (Database $dbForPlatform, callable $get
return function (Document $project) use ($dbForPlatform, $getProjectDB) {
if ($project->isEmpty() || $project->getId() === 'console') {
$adapter = new AdapterDatabase($dbForPlatform);
return new UtopiaAudit($adapter);
}
$dbForProject = $getProjectDB($project);
$adapter = new AdapterDatabase($dbForProject);
return new UtopiaAudit($adapter);
};
}, ['dbForPlatform', 'getProjectDB']);
@@ -505,7 +507,7 @@ $pools = $register->get('pools');
$platform = new Appwrite();
$args = $platform->getEnv('argv');
if (!isset($args[1])) {
if (! isset($args[1])) {
Console::error('Missing worker name');
Console::exit(1);
}
@@ -530,10 +532,10 @@ try {
'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1),
'connection' => $pools->get('consumer')->pop()->getResource(),
'workerName' => strtolower($workerName) ?? null,
'queueName' => $queueName
'queueName' => $queueName,
]);
} catch (\Throwable $e) {
Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
}
$worker = $platform->getWorker();
@@ -550,11 +552,11 @@ $worker
->inject('pools')
->inject('project')
->inject('authorization')
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($worker, $queueName) {
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($queueName) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
if ($logger) {
$log->setNamespace("appwrite-worker");
$log->setNamespace('appwrite-worker');
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
+11 -7
View File
@@ -13,8 +13,11 @@
"test": "vendor/bin/phpunit",
"lint": "vendor/bin/pint --test --config pint.json",
"format": "vendor/bin/pint --config pint.json",
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
"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"
},
"autoload": {
"psr-4": {
@@ -58,7 +61,7 @@
"utopia-php/compression": "0.1.*",
"utopia-php/config": "1.*",
"utopia-php/console": "0.1.*",
"utopia-php/database": "5.*",
"utopia-php/database": "dev-fix-tenant as 5.3.16",
"utopia-php/detector": "0.2.*",
"utopia-php/domains": "1.*",
"utopia-php/emails": "0.6.*",
@@ -70,12 +73,13 @@
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.20.*",
"utopia-php/migration": "1.6.*",
"utopia-php/migration": "1.7.*",
"utopia-php/platform": "0.7.*",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
"utopia-php/queue": "0.15.*",
"utopia-php/servers": "0.2.5",
"utopia-php/registry": "0.5.*",
"utopia-php/storage": "1.0.*",
"utopia-php/system": "0.10.*",
@@ -90,7 +94,8 @@
"spomky-labs/otphp": "11.*",
"webonyx/graphql-php": "14.11.*",
"league/csv": "9.14.*",
"enshrined/svg-sanitize": "0.22.*"
"enshrined/svg-sanitize": "0.22.*",
"utopia-php/di": "0.1.0"
},
"require-dev": {
"ext-fileinfo": "*",
@@ -98,11 +103,10 @@
"brianium/paratest": "7.*",
"phpunit/phpunit": "12.*",
"swoole/ide-helper": "6.*",
"phpstan/phpstan": "1.12.*",
"phpstan/phpstan": "^2.0",
"textalk/websocket": "1.5.*",
"czproject/git-php": "4.*",
"laravel/pint": "1.*",
"phpbench/phpbench": "1.*"
"laravel/pint": "1.*"
},
"provide": {
"ext-phpiredis": "*"
Generated
+91 -756
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
# Dev tools for local development only.
# This file is automatically loaded by `docker compose` alongside docker-compose.yml.
# CI sets COMPOSE_FILE=docker-compose.yml explicitly, so these services are excluded from test runs.
services:
appwrite-mongo-express:
profiles: ["mongodb"]
image: mongo-express
container_name: appwrite-mongo-express
networks:
- appwrite
ports:
- "8082:8081"
environment:
ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true"
ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER}
ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS}
depends_on:
- mongodb
adminer:
image: adminer
container_name: appwrite-adminer
restart: always
ports:
- 9506:8080
networks:
- appwrite
- gateway
environment:
- ADMINER_DESIGN=pepa-linha
- ADMINER_DEFAULT_SERVER=mariadb
- ADMINER_DEFAULT_USERNAME=root
- ADMINER_DEFAULT_PASSWORD=rootsecretpassword
- ADMINER_DEFAULT_DB=appwrite
configs:
- source: adminer-index.php
target: /var/www/html/index.php
mode: 0755
labels:
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=gateway"
- "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080"
- "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web"
- "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)"
- "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer"
- "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure"
- "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)"
- "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer"
- "traefik.http.routers.appwrite_adminer_https.tls=true"
redis-insight:
image: redis/redisinsight:latest
restart: unless-stopped
networks:
- appwrite
- gateway
environment:
- RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json
configs:
- source: redisinsight-connections.json
target: /mnt/connections.json
mode: 0755
labels:
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=gateway"
- "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540"
- "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web"
- "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)"
- "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight"
- "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure"
- "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)"
- "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight"
- "traefik.http.routers.appwrite_redisinsight_https.tls=true"
ports:
- "8081:5540"
graphql-explorer:
container_name: appwrite-graphql-explorer
image: appwrite/altair:0.3.0
restart: unless-stopped
networks:
- appwrite
ports:
- "9509:3000"
environment:
- SERVER_URL=http://localhost/v1/graphql
configs:
redisinsight-connections.json:
content: |
[
{
"compressor": "NONE",
"id": "104dc90a-21ef-4d5e-8912-b30baabb152f",
"host": "redis",
"port": 6379,
"name": "redis:6379",
"db": 0,
"username": "default",
"password": null,
"connectionType": "STANDALONE",
"nameFromProvider": null,
"provider": "REDIS",
"lastConnection": "2025-10-16T09:22:02.591Z",
"modules": [
{
"name": "ReJSON",
"version": 20808,
"semanticVersion": "2.8.8"
},
{
"name": "search",
"version": 21015,
"semanticVersion": "2.10.15"
}
],
"tls": false,
"tlsServername": null,
"verifyServerCert": null,
"caCert": null,
"clientCert": null,
"ssh": false,
"sshOptions": null,
"forceStandalone": false,
"tags": []
}
]
adminer-index.php:
content: |
<?php
if(!count($$_GET)) {
$$_POST['auth'] = [
'server' => $$_ENV['ADMINER_DEFAULT_SERVER'],
'driver' => 'server', /* seems to autodetect the driver from server settings */
'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
'db' => $$_ENV['ADMINER_DEFAULT_DB'],
];
}
include './adminer.php';
+60 -173
View File
@@ -10,6 +10,15 @@ x-logging: &x-logging
max-file: "5"
max-size: "10m"
x-build: &x-build
build:
context: .
target: development
args:
DEBUG: false
TESTING: true
VERSION: dev
services:
traefik:
image: traefik:3.6
@@ -50,15 +59,13 @@ services:
appwrite:
container_name: appwrite
<<: *x-logging
<<: [*x-logging, *x-build]
image: appwrite-dev
build:
context: .
target: development
args:
DEBUG: false
TESTING: true
VERSION: dev
healthcheck:
test: ["CMD", "doctor"]
interval: 5s
timeout: 5s
retries: 12
ports:
- 9501:80
networks:
@@ -101,10 +108,10 @@ services:
- ./dev:/usr/src/code/dev
depends_on:
- ${_APP_DB_HOST:-mongodb}
- redis
- coredns
# - clamav
redis:
condition: service_healthy
coredns:
condition: service_started
entrypoint:
- php
- -e
@@ -260,7 +267,7 @@ services:
appwrite-realtime:
entrypoint: realtime
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-realtime
image: appwrite-dev
restart: unless-stopped
@@ -312,7 +319,7 @@ services:
appwrite-worker-audits:
entrypoint: worker-audits
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-audits
image: appwrite-dev
networks:
@@ -343,7 +350,7 @@ services:
appwrite-worker-webhooks:
entrypoint: worker-webhooks
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-webhooks
image: appwrite-dev
networks:
@@ -378,7 +385,7 @@ services:
appwrite-worker-deletes:
entrypoint: worker-deletes
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-deletes
image: appwrite-dev
networks:
@@ -443,7 +450,7 @@ services:
appwrite-worker-databases:
entrypoint: worker-databases
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-databases
image: appwrite-dev
networks:
@@ -476,7 +483,7 @@ services:
appwrite-worker-builds:
entrypoint: worker-builds
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-builds
image: appwrite-dev
networks:
@@ -551,7 +558,7 @@ services:
appwrite-worker-screenshots:
entrypoint: worker-screenshots
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-screenshots
image: appwrite-dev
networks:
@@ -614,7 +621,7 @@ services:
appwrite-worker-certificates:
entrypoint: worker-certificates
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-certificates
image: appwrite-dev
networks:
@@ -656,7 +663,7 @@ services:
appwrite-worker-executions:
entrypoint: worker-executions
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-executions
image: appwrite-dev
networks:
@@ -686,7 +693,7 @@ services:
appwrite-worker-functions:
entrypoint: worker-functions
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-functions
image: appwrite-dev
networks:
@@ -730,7 +737,7 @@ services:
appwrite-worker-mails:
entrypoint: worker-mails
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-mails
image: appwrite-dev
networks:
@@ -772,7 +779,7 @@ services:
appwrite-worker-messaging:
entrypoint: worker-messaging
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-messaging
restart: unless-stopped
image: appwrite-dev
@@ -829,7 +836,7 @@ services:
appwrite-worker-migrations:
entrypoint: worker-migrations
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-migrations
restart: unless-stopped
image: appwrite-dev
@@ -874,7 +881,7 @@ services:
appwrite-task-maintenance:
entrypoint: maintenance
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-task-maintenance
image: appwrite-dev
networks:
@@ -920,7 +927,7 @@ services:
appwrite-task-interval:
entrypoint: interval
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-task-interval
image: appwrite-dev
networks:
@@ -961,7 +968,7 @@ services:
appwrite-task-stats-resources:
container_name: appwrite-task-stats-resources
entrypoint: stats-resources
<<: *x-logging
<<: [*x-logging, *x-build]
image: appwrite-dev
networks:
- appwrite
@@ -993,7 +1000,7 @@ services:
appwrite-worker-stats-resources:
entrypoint: worker-stats-resources
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-stats-resources
image: appwrite-dev
networks:
@@ -1026,7 +1033,7 @@ services:
appwrite-worker-stats-usage:
entrypoint: worker-stats-usage
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-worker-stats-usage
image: appwrite-dev
networks:
@@ -1059,7 +1066,7 @@ services:
appwrite-task-scheduler-functions:
entrypoint: schedule-functions
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-task-scheduler-functions
image: appwrite-dev
networks:
@@ -1089,7 +1096,7 @@ services:
appwrite-task-scheduler-executions:
entrypoint: schedule-executions
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-task-scheduler-executions
image: appwrite-dev
networks:
@@ -1115,10 +1122,11 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DATABASE_SHARED_TABLES
appwrite-task-scheduler-messages:
entrypoint: schedule-messages
<<: *x-logging
<<: [*x-logging, *x-build]
container_name: appwrite-task-scheduler-messages
image: appwrite-dev
networks:
@@ -1237,6 +1245,11 @@ services:
- MYSQL_PASSWORD=${_APP_DB_PASS}
- MARIADB_AUTO_UPGRADE=1
command: "mysqld --innodb-flush-method=fsync"
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 12
mongodb:
profiles: ["mongodb"]
@@ -1275,20 +1288,7 @@ services:
retries: 10
start_period: 30s
appwrite-mongo-express:
profiles: ["mongodb"]
image: mongo-express
container_name: appwrite-mongo-express
networks:
- appwrite
ports:
- "8082:8081"
environment:
ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true"
ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER}
ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS}
depends_on:
- mongodb
postgresql:
profiles: ["postgresql"]
@@ -1301,7 +1301,7 @@ services:
networks:
- appwrite
volumes:
- appwrite-postgresql:/var/lib/postgresql/data:rw
- appwrite-postgresql:/var/lib/postgresql:rw
ports:
- "5432:5432"
environment:
@@ -1309,6 +1309,11 @@ services:
- POSTGRES_USER=${_APP_DB_USER}
- POSTGRES_PASSWORD=${_APP_DB_PASS}
command: "postgres"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER}"]
interval: 5s
timeout: 5s
retries: 12
redis:
image: redis:7.4.7-alpine
@@ -1325,6 +1330,11 @@ services:
- appwrite
volumes:
- appwrite-redis:/data:rw
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 12
coredns: # DNS server for testing purposes (Proxy APIs)
image: coredns/coredns:1.12.4
@@ -1396,78 +1406,8 @@ services:
networks:
- appwrite
adminer:
image: adminer
container_name: appwrite-adminer
<<: *x-logging
restart: always
ports:
- 9506:8080
networks:
- appwrite
- gateway
environment:
- ADMINER_DESIGN=pepa-linha
- ADMINER_DEFAULT_SERVER=mariadb
- ADMINER_DEFAULT_USERNAME=root
- ADMINER_DEFAULT_PASSWORD=rootsecretpassword
- ADMINER_DEFAULT_DB=appwrite
configs:
- source: adminer-index.php
target: /var/www/html/index.php
mode: 0755
labels:
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=gateway"
- "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080"
- "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web"
- "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)"
- "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer"
- "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure"
- "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)"
- "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer"
- "traefik.http.routers.appwrite_adminer_https.tls=true"
redis-insight:
image: redis/redisinsight:latest
restart: unless-stopped
networks:
- appwrite
- gateway
environment:
- RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json
configs:
- source: redisinsight-connections.json
target: /mnt/connections.json
mode: 0755
labels:
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=gateway"
- "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540"
- "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web"
- "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)"
- "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight"
- "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure"
- "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)"
- "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight"
- "traefik.http.routers.appwrite_redisinsight_https.tls=true"
ports:
- "8081:5540"
graphql-explorer:
container_name: appwrite-graphql-explorer
image: appwrite/altair:0.3.0
restart: unless-stopped
networks:
- appwrite
ports:
- "9509:3000"
environment:
- SERVER_URL=http://localhost/v1/graphql
# Dev Tools End ------------------------------------------------------------------------------------------
# Dev tools (adminer, redis-insight, mongo-express, graphql-explorer)
# are defined in docker-compose.override.yml
networks:
gateway:
@@ -1480,60 +1420,7 @@ networks:
runtimes:
name: runtimes
configs:
redisinsight-connections.json:
content: |
[
{
"compressor": "NONE",
"id": "104dc90a-21ef-4d5e-8912-b30baabb152f",
"host": "redis",
"port": 6379,
"name": "redis:6379",
"db": 0,
"username": "default",
"password": null,
"connectionType": "STANDALONE",
"nameFromProvider": null,
"provider": "REDIS",
"lastConnection": "2025-10-16T09:22:02.591Z",
"modules": [
{
"name": "ReJSON",
"version": 20808,
"semanticVersion": "2.8.8"
},
{
"name": "search",
"version": 21015,
"semanticVersion": "2.10.15"
}
],
"tls": false,
"tlsServername": null,
"verifyServerCert": null,
"caCert": null,
"clientCert": null,
"ssh": false,
"sshOptions": null,
"forceStandalone": false,
"tags": []
}
]
adminer-index.php:
content: |
<?php
if(!count($$_GET)) {
$$_POST['auth'] = [
'server' => $$_ENV['ADMINER_DEFAULT_SERVER'],
'driver' => 'server', /* seems to autodetect the driver from server settings */
'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
'db' => $$_ENV['ADMINER_DEFAULT_DB'],
];
}
include './adminer.php';
volumes:
appwrite-mariadb:
+63 -3
View File
@@ -20,10 +20,16 @@ client = Client()
### Make Your First Request
Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section.
All service methods return typed Pydantic models, so you can access response fields as attributes:
```python
users = Users(client)
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
print(user.name) # "Walter O'Brien"
print(user.email) # "email@example.com"
print(user.id) # The generated user ID
```
### Full Example
@@ -43,7 +49,60 @@ client = Client()
users = Users(client)
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
print(user.name) # Access fields as attributes
print(user.to_dict()) # Convert to dictionary if needed
```
### Type Safety with Models
The Appwrite Python SDK provides type safety when working with database rows through generic methods. Methods like `get_row`, `list_rows`, and others accept a `model_type` parameter that allows you to specify your custom Pydantic model for full type safety.
```python
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
from appwrite.client import Client
from appwrite.services.tables_db import TablesDB
# Define your custom model matching your table schema
class Post(BaseModel):
postId: int
authorId: int
title: str
content: str
createdAt: datetime
updatedAt: datetime
isPublished: bool
excerpt: Optional[str] = None
client = Client()
# ... configure your client ...
tables_db = TablesDB(client)
# Fetch a single row with type safety
row = tables_db.get_row(
database_id="your-database-id",
table_id="your-table-id",
row_id="your-row-id",
model_type=Post # Pass your custom model type
)
print(row.data.title) # Fully typed - IDE autocomplete works
print(row.data.postId) # int type, not Any
print(row.data.createdAt) # datetime type
# Fetch multiple rows with type safety
result = tables_db.list_rows(
database_id="your-database-id",
table_id="your-table-id",
model_type=Post
)
for row in result.rows:
print(f"{row.data.title} by {row.data.authorId}")
```
### Error Handling
@@ -52,7 +111,8 @@ The Appwrite Python SDK raises `AppwriteException` object with `message`, `code`
```python
users = Users(client)
try:
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
print(user.name)
except AppwriteException as e:
print(e.message)
```
Executable → Regular
View File
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@appwrite.io/repo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@appwrite.io/repo"
}
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"$schema":"vendor/phpbench/phpbench/phpbench.schema.json",
"runner.bootstrap": "vendor/autoload.php",
"runner.path": "tests",
"runner.file_pattern": "*Bench.php"
}
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -1,13 +1,17 @@
includes:
- phpstan-baseline.neon
parameters:
level: 8
level: 3
paths:
- src/Utopia/Bus
- src/Appwrite/Bus
- src/Appwrite/Transformation
- src
- app
- bin
- tests
bootstrapFiles:
- app/init/constants.php
scanDirectories:
- vendor/swoole/ide-helper
excludePaths:
- tests/resources
- app/sdks
+1
View File
@@ -34,6 +34,7 @@
<directory>./tests/e2e/Services/Storage</directory>
<directory>./tests/e2e/Services/Tokens</directory>
<directory>./tests/e2e/Services/Webhooks</directory>
<directory>./tests/e2e/Services/ProjectWebhooks</directory>
<directory>./tests/e2e/Services/Messaging</directory>
<directory>./tests/e2e/Services/Migrations</directory>
<file>./tests/e2e/Services/Functions/FunctionsBase.php</file>
+22 -21
View File
@@ -4,11 +4,12 @@ namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Message\Usage as UsageMessage;
use Appwrite\Event\Publisher\Usage as Publisher;
use Appwrite\Usage\Context;
use Utopia\Bus\Event;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
class Usage extends Listener
{
@@ -29,20 +30,21 @@ class Usage extends Listener
{
$this
->desc('Records usage metrics')
->inject('publisherStatsUsage')
->inject('publisherForUsage')
->inject('usage')
->callback($this->handle(...));
}
public function handle(Event $event, Publisher $publisher): void
public function handle(Event $event, Publisher $publisherForUsage, Context $usage): void
{
match (true) {
$event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisher),
$event instanceof RequestCompleted => $this->handleRequestCompleted($event, $publisher),
$event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisherForUsage),
$event instanceof RequestCompleted => $this->handleRequestCompleted($event, $usage),
default => null,
};
}
private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisher): void
private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisherForUsage): void
{
$execution = new Document($event->execution);
$resource = new Document($event->resource);
@@ -61,9 +63,7 @@ class Usage extends Listener
$compute = (int)($duration * 1000);
$mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $duration * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
$queueForStatsUsage = new StatsUsage($publisher);
$queueForStatsUsage
->setProject($project)
$context = (new Context())
->addMetric(METRIC_EXECUTIONS, 1)
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS), 1)
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1)
@@ -72,11 +72,18 @@ class Usage extends Listener
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE), $compute)
->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds)
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), $mbSeconds)
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds)
->trigger();
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds);
$message = new UsageMessage(
project: $project,
metrics: $context->getMetrics(),
reduce: $context->getReduce()
);
$publisherForUsage->enqueue($message);
}
private function handleRequestCompleted(RequestCompleted $event, Publisher $publisher): void
private function handleRequestCompleted(RequestCompleted $event, Context $usage): void
{
$fileSize = 0;
$file = $event->request->getFiles('file');
@@ -84,18 +91,14 @@ class Usage extends Listener
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
}
$project = new Document($event->project);
$deployment = new Document($event->deployment);
$queueForStatsUsage = new StatsUsage($publisher);
$inbound = $event->request->getSize() + $fileSize;
$outbound = $event->response->getSize();
$queueForStatsUsage->setProject($project);
if ($deployment->getAttribute('resourceType') === 'sites') {
$siteInternalId = $deployment->getAttribute('resourceInternalId', '');
$queueForStatsUsage
$usage
->addMetric(METRIC_SITES_REQUESTS, 1)
->addMetric(METRIC_SITES_INBOUND, $inbound)
->addMetric(METRIC_SITES_OUTBOUND, $outbound)
@@ -103,12 +106,10 @@ class Usage extends Listener
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_INBOUND), $inbound)
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_OUTBOUND), $outbound);
} else {
$queueForStatsUsage
$usage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $inbound)
->addMetric(METRIC_NETWORK_OUTBOUND, $outbound);
}
$queueForStatsUsage->trigger();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Appwrite\Event\Message;
abstract class Base
{
/**
* Serialize message to array for queue
*
* @return array
*/
abstract public function toArray(): array;
/**
* Deserialize message from array
*
* @param array $data
* @return static
*/
abstract public static function fromArray(array $data): static;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace Appwrite\Event\Message;
use Utopia\Database\Document;
class Usage extends Base
{
/**
* @param Document $project
* @param array<array{key: string, value: int}> $metrics
* @param array<Document> $reduce
*/
public function __construct(
public readonly Document $project,
public readonly array $metrics,
public readonly array $reduce = [],
) {
}
/**
* @return array
*/
public function toArray(): array
{
return [
'project' => [
'$id' => $this->project->getId(),
'$sequence' => $this->project->getSequence(),
'database' => $this->project->getAttribute('database', ''),
],
'metrics' => $this->metrics,
'reduce' => array_map(fn (Document $doc) => $doc->getArrayCopy(), $this->reduce),
];
}
/**
* @param array $data
* @return static
*/
public static function fromArray(array $data): static
{
return new self(
project: new Document($data['project'] ?? []),
metrics: $data['metrics'] ?? [],
reduce: array_map(fn (array $doc) => new Document($doc), $data['reduce'] ?? []),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Appwrite\Event\Publisher;
use Appwrite\Event\Message\Base as BaseMessage;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
readonly class Base
{
public function __construct(
protected Publisher $publisher
) {
}
/**
* Publish a message to the queue
*/
public function publish(Queue $queue, BaseMessage $message): string|bool
{
$payload = $message->toArray();
return $this->publisher->enqueue($queue, $payload);
}
/**
* Get the size of a queue
*/
public function getQueueSize(Queue $queue, bool $failed = false): int
{
return $this->publisher->getQueueSize($queue, $failed);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Appwrite\Event\Publisher;
use Appwrite\Event\Message\Usage as UsageMessage;
use Utopia\Console;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
readonly class Usage extends Base
{
public function __construct(
Publisher $publisher,
protected Queue $queue
) {
parent::__construct($publisher);
}
/**
* Enqueue a usage message
*/
public function enqueue(UsageMessage $message): string|bool
{
try {
return $this->publish($this->queue, $message);
} catch (\Throwable $th) {
Console::error('[Usage] Failed to publish usage message: ' . $th->getMessage());
return false;
}
}
/**
* Get the size of the usage queue
*/
public function getSize(bool $failed = false): int
{
return $this->getQueueSize($this->queue, $failed);
}
}
-96
View File
@@ -1,96 +0,0 @@
<?php
namespace Appwrite\Event;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
use Utopia\System\System;
class StatsUsage extends Event
{
protected array $metrics = [];
protected array $reduce = [];
protected array $disabled = [];
protected bool $critical = false;
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->setQueue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
->setClass(System::getEnv('_APP_STATS_USAGE_CLASS_NAME', Event::STATS_USAGE_CLASS_NAME));
}
/**
* Add reduce.
*
* @param Document $document
* @return self
*/
public function addReduce(Document $document): self
{
$this->reduce[] = $document;
return $this;
}
/**
* Add metric.
*
* @param string $key
* @param int $value
* @return self
*/
public function addMetric(string $key, int $value): self
{
$this->metrics[] = [
'key' => $key,
'value' => $value,
];
return $this;
}
/**
* Set disabled metrics.
*
* @param string $key
* @return self
*/
public function disableMetric(string $key): self
{
$this->disabled[] = $key;
return $this;
}
/**
* Prepare the payload for the event
*
* @return array
*/
protected function preparePayload(): array
{
return [
'project' => $this->getProject(),
'reduce' => $this->reduce,
'metrics' => \array_filter($this->metrics, function ($metric) {
foreach ($this->disabled as $disabledMetric) {
if (\str_ends_with($metric['key'], $disabledMetric)) {
return false;
}
}
return true;
}),
];
}
public function reset(): Event
{
$this->metrics = [];
parent::reset();
return $this;
}
}
+1
View File
@@ -307,6 +307,7 @@ class Exception extends \Exception
/** Webhooks */
public const string WEBHOOK_NOT_FOUND = 'webhook_not_found';
public const string WEBHOOK_ALREADY_EXISTS = 'webhook_already_exists';
/** Router */
public const string ROUTER_HOST_NOT_FOUND = 'router_host_not_found';
+2
View File
@@ -16,6 +16,7 @@ use Appwrite\Platform\Modules\Storage;
use Appwrite\Platform\Modules\Teams;
use Appwrite\Platform\Modules\Tokens;
use Appwrite\Platform\Modules\VCS;
use Appwrite\Platform\Modules\Webhooks;
use Utopia\Platform\Platform;
class Appwrite extends Platform
@@ -36,5 +37,6 @@ class Appwrite extends Platform
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
}
}
@@ -0,0 +1,72 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Appwrite\Platform\Installer\Runtime\State;
use Appwrite\Platform\Installer\Server;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
use Utopia\Validator\Text;
class Complete extends Action
{
public static function getName(): string
{
return 'installerComplete';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/install/complete')
->desc('Complete installation')
->param('installId', '', new Text(64, 0), 'Installation ID', true)
->param('sessionId', '', new Text(256, 0), 'Session ID', true)
->param('sessionSecret', '', new Text(256, 0), 'Session secret', true)
->param('sessionExpire', '', new Text(64, 0), 'Session expiry timestamp', true)
->inject('request')
->inject('response')
->inject('installerState')
->callback($this->action(...));
}
public function action(string $installId, string $sessionId, string $sessionSecret, string $sessionExpire, Request $request, Response $response, State $state): void
{
if (!Validate::validateCsrf($request)) {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['success' => false, 'message' => 'Invalid CSRF token']);
return;
}
$installId = $state->sanitizeInstallId($installId);
if ($installId !== '') {
$state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
}
@touch(Server::INSTALLER_COMPLETE_FILE);
if ($sessionSecret) {
$isHttps = $request->getProtocol() === 'https';
$sameSite = $isHttps ? Response::COOKIE_SAMESITE_NONE : Response::COOKIE_SAMESITE_LAX;
$expires = 0;
if ($sessionExpire) {
$timestamp = strtotime($sessionExpire);
if ($timestamp !== false) {
$expires = $timestamp;
}
}
$response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite);
$response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite);
if ($sessionId) {
$response->addHeader('X-Appwrite-Session', $sessionId);
}
}
@unlink(Server::INSTALLER_CONFIG_FILE);
$response->json(['success' => true]);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
class Error extends Action
{
public static function getName(): string
{
return 'installerError';
}
public function __construct()
{
$this
->setType(Action::TYPE_ERROR)
->inject('error')
->inject('response')
->callback($this->action(...));
}
public function action(\Throwable $error, Response $response): void
{
if ($response->isSent()) {
return;
}
$code = $error->getCode();
if ($code < 100 || $code > 599) {
$code = 500;
}
$response->setStatusCode($code);
$message = $code >= 500 ? 'Internal installer error' : $error->getMessage();
$response->json(['success' => false, 'message' => $message]);
}
}
@@ -0,0 +1,398 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Appwrite\Auth\Validator\Password;
use Appwrite\Platform\Installer\Runtime\Config;
use Appwrite\Platform\Installer\Runtime\State;
use Appwrite\Platform\Installer\Server;
use Appwrite\Platform\Installer\Validator\AppDomain;
use Swoole\Http\Response as SwooleResponse;
use Utopia\Emails\Validator\Email;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Install extends Action
{
private const int SSE_KEEPALIVE_DELAY_MICROSECONDS = 500000;
public static function getName(): string
{
return 'installerInstall';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/install')
->desc('Run installation')
->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)')
->param('httpPort', 80, new Range(1, 65535), 'HTTP port')
->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port')
->param('emailCertificates', '', new Email(), 'Email for SSL certificates')
->param('opensslKey', '', new Text(64, 0), 'Secret API key', true)
->param('assistantOpenAIKey', '', new Text(256, 0), 'OpenAI API key for assistant', true)
->param('accountEmail', '', new Email(allowEmpty: true), 'Account email address', true)
->param('accountPassword', '', new Password(allowEmpty: true), 'Account password', true)
->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true)
->param('installId', '', new Text(64, 0), 'Installation ID', true)
->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true)
->inject('request')
->inject('response')
->inject('swooleResponse')
->inject('installerState')
->inject('installerConfig')
->inject('installerPaths')
->callback($this->action(...));
}
public function action(
string $appDomain,
int $httpPort,
int $httpsPort,
string $emailCertificates,
string $opensslKey,
string $assistantOpenAIKey,
string $accountEmail,
string $accountPassword,
string $database,
string $installId,
?string $retryStep,
Request $request,
Response $response,
SwooleResponse $swooleResponse,
State $state,
Config $config,
array $paths
): void {
$acceptHeader = $request->getHeader('accept');
$wantsStream = stripos($acceptHeader, 'text/event-stream') !== false;
if ($wantsStream) {
$swooleResponse->header('Content-Type', 'text/event-stream');
$swooleResponse->header('Cache-Control', 'no-cache');
$swooleResponse->header('Connection', 'keep-alive');
$swooleResponse->header('X-Accel-Buffering', 'no');
$swooleResponse->write("event: ping\ndata: {\"time\":" . time() . "}\n\n");
}
if (!Validate::validateCsrf($request)) {
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Invalid CSRF token');
return;
}
$appDomain = trim($appDomain);
$emailCertificates = trim($emailCertificates);
$opensslKey = trim($opensslKey);
$assistantOpenAIKey = trim($assistantOpenAIKey);
if ($opensslKey === '' && !$config->isUpgrade()) {
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Secret key is required');
return;
}
$account = [];
if (!$config->isUpgrade()) {
$accountEmail = trim($accountEmail);
if ($accountEmail === '' || !$state->isValidEmailAddress($accountEmail)) {
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Please enter a valid email address', Server::STEP_ACCOUNT_SETUP);
return;
}
if (!$state->isValidPassword($accountPassword)) {
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Password must be at least 8 characters', Server::STEP_ACCOUNT_SETUP);
return;
}
$accountName = $this->deriveNameFromEmail($accountEmail);
$account = [
'name' => $accountName,
'email' => $accountEmail,
'password' => $accountPassword,
];
}
$lockedDatabase = $config->getLockedDatabase();
if (!$lockedDatabase) {
$database = strtolower(trim($database));
if (!$state->isValidDatabaseAdapter($database)) {
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Please select a supported database');
return;
}
}
$installId = $state->sanitizeInstallId($installId);
if ($installId === '') {
$installId = bin2hex(random_bytes(8));
}
@unlink(Server::INSTALLER_COMPLETE_FILE);
try {
$lockResult = $state->reserveGlobalLock($installId);
} catch (\Throwable $e) {
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Lock failed: ' . $e->getMessage()]);
$swooleResponse->end();
} else {
$response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
$response->json(['success' => false, 'message' => 'Lock failed: ' . $e->getMessage()]);
}
return;
}
if ($lockResult !== 'ok') {
$lockMessage = $lockResult === 'locked'
? 'Installation already in progress'
: 'Installer lock unavailable';
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => $lockMessage]);
$swooleResponse->end();
} else {
$statusCode = $lockResult === 'locked'
? Response::STATUS_CODE_CONFLICT
: Response::STATUS_CODE_SERVICE_UNAVAILABLE;
$response->setStatusCode($statusCode);
$response->json(['success' => false, 'message' => $lockMessage]);
}
return;
}
$existingPath = $state->progressFilePath($installId);
$existing = null;
if (file_exists($existingPath)) {
$existing = $state->readProgressFile($installId);
if (!empty($existing['steps']) && $retryStep === null) {
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']);
$swooleResponse->end();
} else {
$response->setStatusCode(Response::STATUS_CODE_CONFLICT);
$response->json(['success' => false, 'message' => 'Installation already started']);
}
return;
}
}
try {
$state->ensureBootstrapped();
$installer = new \Appwrite\Platform\Tasks\Install();
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'install-id', ['installId' => $installId]);
}
$state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS);
$payloadInput = [
'_APP_ENV' => 'production',
'_APP_OPENSSL_KEY_V1' => $opensslKey,
'_APP_DOMAIN' => $appDomain ?: 'localhost',
'_APP_DOMAIN_TARGET' => $appDomain ?: 'localhost',
'_APP_EMAIL_CERTIFICATES' => $emailCertificates,
'_APP_DB_ADAPTER' => $lockedDatabase ?? ($database ?: 'mongodb'),
'_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey,
];
if ($this->hasPayload($existing)) {
$stored = $existing['payload'];
$inputValues = [
'httpPort' => (string) $httpPort,
'httpsPort' => (string) $httpsPort,
'database' => $database,
'appDomain' => $appDomain,
'emailCertificates' => $emailCertificates,
];
foreach ($inputValues as $field => $inputValue) {
if (isset($stored[$field]) && $inputValue !== '') {
$storedValue = (string) $stored[$field];
if (in_array($field, ['httpPort', 'httpsPort'], true)) {
$storedValue = trim($storedValue);
$inputValue = trim($inputValue);
}
if ($storedValue !== $inputValue) {
if ($installId !== '') {
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
}
}
$sensitiveFields = [
'opensslKey' => ['hash' => 'opensslKeyHash', 'value' => $opensslKey],
'assistantOpenAIKey' => ['hash' => 'assistantOpenAIKeyHash', 'value' => $assistantOpenAIKey],
];
foreach ($sensitiveFields as $field => $info) {
$hashField = $info['hash'];
$incomingValue = $info['value'];
if (!isset($stored[$hashField]) && !isset($stored[$field])) {
continue;
}
$incomingHash = $state->hashSensitiveValue($incomingValue);
if (isset($stored[$hashField])) {
if (!hash_equals((string) $stored[$hashField], $incomingHash)) {
if ($installId !== '') {
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
} elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) {
if ($installId !== '') {
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
}
$payloadInput['_APP_DOMAIN'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN'];
$payloadInput['_APP_DOMAIN_TARGET'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN_TARGET'];
$payloadInput['_APP_EMAIL_CERTIFICATES'] = $stored['emailCertificates'] ?? $payloadInput['_APP_EMAIL_CERTIFICATES'];
$payloadInput['_APP_DB_ADAPTER'] = $lockedDatabase ?? ($stored['database'] ?? $payloadInput['_APP_DB_ADAPTER']);
$httpPort = (int) ($stored['httpPort'] ?? $httpPort ?: $config->getDefaultHttpPort());
$httpsPort = (int) ($stored['httpsPort'] ?? $httpsPort ?: $config->getDefaultHttpsPort());
}
$vars = $config->getVars();
$shouldGenerateSecrets = !$installer->hasExistingConfig() && !$config->isUpgrade();
$envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars, $shouldGenerateSecrets);
$state->writeProgressFile($installId, [
'payload' => [
'httpPort' => $httpPort ?: $config->getDefaultHttpPort(),
'httpsPort' => $httpsPort ?: $config->getDefaultHttpsPort(),
'database' => $lockedDatabase ?? ($database ?: 'mongodb'),
'appDomain' => $appDomain ?: 'localhost',
'emailCertificates' => $emailCertificates,
'opensslKeyHash' => $state->hashSensitiveValue($opensslKey),
'assistantOpenAIKeyHash' => $state->hashSensitiveValue($assistantOpenAIKey),
],
'step' => 'start',
'status' => Server::STATUS_IN_PROGRESS,
'message' => 'Installation started',
'updatedAt' => time(),
]);
$progress = function (string $step, string $status, string $message, array $details = []) use ($installId, $wantsStream, $swooleResponse, $state) {
$payload = [
'installId' => $installId,
'step' => $step,
'status' => $status,
'message' => $message,
'updatedAt' => time(),
];
if (!empty($details)) {
$payload['details'] = $details;
}
$state->writeProgressFile($installId, $payload);
$state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'progress', $payload);
}
};
$installer->performInstallation(
$httpPort ?: $config->getDefaultHttpPort(),
$httpsPort ?: $config->getDefaultHttpsPort(),
$config->getOrganization(),
$config->getImage(),
$envVars,
$config->getNoStart(),
$progress,
$retryStep,
$config->isUpgrade(),
$account
);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]);
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->write(": keepalive\n\n");
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->end();
} else {
$response->json([
'success' => true,
'installId' => $installId,
'message' => 'Installation completed successfully',
]);
}
$state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
} catch (\Throwable $e) {
$this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state);
}
}
private function writeSseEvent(SwooleResponse $swooleResponse, string $event, array $payload): void
{
$swooleResponse->write("event: $event\ndata: " . json_encode($payload) . "\n\n");
}
private function sendBadRequest(Response $response, SwooleResponse $swooleResponse, bool $wantsStream, string $message, string $step = Server::STEP_CONFIG_FILES): void
{
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => $message, 'step' => $step]);
$swooleResponse->end();
} else {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['success' => false, 'message' => $message]);
}
}
private function handleInstallationError(\Throwable $e, string $installId, bool $wantsStream, Response $response, SwooleResponse $swooleResponse, State $state): void
{
if ($installId !== '') {
$state->writeProgressFile($installId, [
'step' => Server::STATUS_ERROR,
'status' => Server::STATUS_ERROR,
'message' => $e->getMessage(),
'details' => $this->buildErrorDetails($e),
'updatedAt' => time(),
]);
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
@unlink(Server::INSTALLER_CONFIG_FILE);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [
'message' => $e->getMessage(),
'details' => $this->buildErrorDetails($e)
]);
$swooleResponse->end();
} else {
$response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
$response->json(['success' => false, 'message' => $e->getMessage()]);
}
}
private function buildErrorDetails(\Throwable $e): array
{
return [];
}
private function hasPayload(mixed $data): bool
{
return is_array($data) && isset($data['payload']) && is_array($data['payload']);
}
private function deriveNameFromEmail(string $email): string
{
$parts = explode('@', $email);
$username = $parts[0] ?? '';
$cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username);
return ucfirst($cleaned);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Swoole\Http\Server as SwooleServer;
use Swoole\Timer;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
class Shutdown extends Action
{
private const int SHUTDOWN_DELAY_SECONDS = 2;
public static function getName(): string
{
return 'installerShutdown';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/install/shutdown')
->desc('Shutdown installer server')
->inject('request')
->inject('response')
->inject('swooleServer')
->callback($this->action(...));
}
public function action(Request $request, Response $response, ?SwooleServer $swooleServer): void
{
if (!Validate::validateCsrf($request)) {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['success' => false, 'message' => 'Invalid CSRF token']);
return;
}
$response->json(['success' => true]);
if ($swooleServer) {
Timer::after(self::SHUTDOWN_DELAY_SECONDS * 1000, function () use ($swooleServer) {
$swooleServer->shutdown();
});
}
}
}
@@ -0,0 +1,65 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Appwrite\Platform\Installer\Runtime\State;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
use Utopia\Validator\Text;
class Status extends Action
{
public static function getName(): string
{
return 'installerStatus';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/install/status')
->desc('Poll installation progress')
->param('installId', '', new Text(64, 0), 'Installation ID', true)
->inject('response')
->inject('installerState')
->callback($this->action(...));
}
public function action(string $installId, Response $response, State $state): void
{
$installId = $state->sanitizeInstallId($installId);
if ($installId === '') {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['success' => false, 'message' => 'Missing installId']);
return;
}
$path = $state->progressFilePath($installId);
if (!file_exists($path)) {
$response->setStatusCode(Response::STATUS_CODE_NOT_FOUND);
$response->json(['success' => false, 'message' => 'Install not found']);
return;
}
$data = $state->readProgressFile($installId);
if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) {
unset(
$data['payload']['opensslKey'],
$data['payload']['assistantOpenAIKey'],
$data['payload']['opensslKeyHash'],
$data['payload']['assistantOpenAIKeyHash'],
);
}
// Strip sensitive data from step details
if (is_array($data) && isset($data['details']) && is_array($data['details'])) {
foreach ($data['details'] as $stepKey => &$stepDetails) {
if (is_array($stepDetails)) {
unset($stepDetails['sessionSecret'], $stepDetails['trace']);
}
}
unset($stepDetails);
}
$response->json(['success' => true, 'progress' => $data]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Appwrite\Platform\Installer\Server;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
class Validate extends Action
{
public static function getName(): string
{
return 'installerValidate';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/install/validate')
->desc('Validate CSRF token')
->inject('request')
->inject('response')
->callback($this->action(...));
}
public function action(Request $request, Response $response): void
{
if (!self::validateCsrf($request)) {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['success' => false, 'message' => 'Invalid CSRF token']);
return;
}
$response->json(['success' => true]);
}
public static function validateCsrf(Request $request): bool
{
$cookie = $request->getCookie(Server::CSRF_COOKIE);
$header = $request->getHeader('x-appwrite-installer-csrf');
return $cookie !== '' && $header !== '' && hash_equals($cookie, $header);
}
}
@@ -0,0 +1,90 @@
<?php
namespace Appwrite\Platform\Installer\Http\Installer;
use Appwrite\Platform\Installer\Runtime\Config;
use Appwrite\Platform\Installer\Server;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Platform\Action;
use Utopia\Validator\Integer;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
class View extends Action
{
public static function getName(): string
{
return 'installerView';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/')
->desc('Serve installer UI')
->param('step', 1, new Integer(true), 'Step number (1-5)', true)
->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true)
->inject('request')
->inject('response')
->inject('installerConfig')
->inject('installerPaths')
->callback($this->action(...));
}
public function action(int $step, ?string $partial, Request $request, Response $response, Config $config, array $paths): void
{
$csrfToken = $this->makeCsrf($request, $response);
$response->addHeader('Content-Security-Policy', implode('; ', Server::INSTALLER_CSP));
$vars = $config->getVars();
$defaultHttpPort = $config->getDefaultHttpPort();
$defaultHttpsPort = $config->getDefaultHttpsPort();
$isUpgrade = $config->isUpgrade();
$lockedDatabase = $config->getLockedDatabase();
$isLocalInstall = $config->isLocal();
$defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? '';
if ($isLocalInstall && empty($defaultEmailCertificates)) {
$defaultEmailCertificates = 'walterobrien@example.com';
}
$step = max(1, min(5, $step));
if ($isUpgrade && ($step === 2 || $step === 3)) {
$step = 4;
}
$partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml";
if (!is_file($partialFile)) {
$partialFile = $paths['views'] . '/installer/templates/steps/step-1.phtml';
}
if ($partial !== null) {
ob_start();
include $partialFile;
$html = ob_get_clean();
$response->html($html);
return;
}
ob_start();
include $paths['views'] . '/installer.phtml';
$html = ob_get_clean();
$response->html($html);
}
private function makeCsrf(Request $request, Response $response): string
{
$existing = $request->getCookie(Server::CSRF_COOKIE);
if ($existing !== '') {
return $existing;
}
$token = bin2hex(random_bytes(16));
$response->addCookie(Server::CSRF_COOKIE, $token, null, '/', null, null, true, Response::COOKIE_SAMESITE_STRICT);
return $token;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Appwrite\Platform\Installer;
use Utopia\Platform\Platform;
class Installer extends Platform
{
public function __construct()
{
parent::__construct(new Module());
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Installer;
use Appwrite\Platform\Installer\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,205 @@
<?php
namespace Appwrite\Platform\Installer\Runtime;
final class Config
{
private const array KNOWN_KEYS = [
'defaultHttpPort',
'defaultHttpsPort',
'organization',
'image',
'noStart',
'isUpgrade',
'isLocal',
'hostPath',
'lockedDatabase',
'vars',
];
private string $defaultHttpPort = '80';
private string $defaultHttpsPort = '443';
private string $organization = 'appwrite';
private string $image = 'appwrite';
private bool $noStart = false;
private bool $isUpgrade = false;
private bool $isLocal = false;
private ?string $hostPath = null;
private ?string $lockedDatabase = null;
private array $vars = [];
public function __construct(array $values = [])
{
if (!$this->containsKnownKeys($values)) {
$this->setVars($values);
return;
}
$this->apply($values);
}
public function apply(array $values): void
{
if ($this->hasValidStringValue($values, 'defaultHttpPort')) {
$this->setDefaultHttpPort((string) $values['defaultHttpPort']);
}
if ($this->hasValidStringValue($values, 'defaultHttpsPort')) {
$this->setDefaultHttpsPort((string) $values['defaultHttpsPort']);
}
if ($this->hasValidStringValue($values, 'organization')) {
$this->setOrganization((string) $values['organization']);
}
if ($this->hasValidStringValue($values, 'image')) {
$this->setImage((string) $values['image']);
}
if (array_key_exists('noStart', $values) && $values['noStart'] !== null) {
$this->setNoStart((bool) $values['noStart']);
}
if (array_key_exists('isUpgrade', $values) && $values['isUpgrade'] !== null) {
$this->setIsUpgrade((bool) $values['isUpgrade']);
}
if (array_key_exists('isLocal', $values) && $values['isLocal'] !== null) {
$this->setIsLocal((bool) $values['isLocal']);
}
if (array_key_exists('hostPath', $values)) {
$hostPath = $values['hostPath'];
$this->setHostPath($hostPath !== null && $hostPath !== '' ? (string) $hostPath : null);
}
if ($this->hasValidStringValue($values, 'lockedDatabase')) {
$this->setLockedDatabase((string) $values['lockedDatabase']);
}
if (array_key_exists('vars', $values) && is_array($values['vars'])) {
$this->setVars($values['vars']);
}
}
private function hasValidStringValue(array $values, string $key): bool
{
return array_key_exists($key, $values) && $values[$key] !== null && $values[$key] !== '';
}
private function containsKnownKeys(array $values): bool
{
foreach (self::KNOWN_KEYS as $key) {
if (array_key_exists($key, $values)) {
return true;
}
}
return false;
}
public function toArray(): array
{
return [
'defaultHttpPort' => $this->defaultHttpPort,
'defaultHttpsPort' => $this->defaultHttpsPort,
'organization' => $this->organization,
'image' => $this->image,
'noStart' => $this->noStart,
'vars' => $this->vars,
'isUpgrade' => $this->isUpgrade,
'isLocal' => $this->isLocal,
'hostPath' => $this->hostPath,
'lockedDatabase' => $this->lockedDatabase,
];
}
public function getDefaultHttpPort(): string
{
return $this->defaultHttpPort;
}
public function setDefaultHttpPort(string $value): void
{
$this->defaultHttpPort = $value;
}
public function getDefaultHttpsPort(): string
{
return $this->defaultHttpsPort;
}
public function setDefaultHttpsPort(string $value): void
{
$this->defaultHttpsPort = $value;
}
public function getOrganization(): string
{
return $this->organization;
}
public function setOrganization(string $value): void
{
$this->organization = $value;
}
public function getImage(): string
{
return $this->image;
}
public function setImage(string $value): void
{
$this->image = $value;
}
public function getNoStart(): bool
{
return $this->noStart;
}
public function setNoStart(bool $value): void
{
$this->noStart = $value;
}
public function getVars(): array
{
return $this->vars;
}
public function setVars(array $vars): void
{
$this->vars = $vars;
}
public function isUpgrade(): bool
{
return $this->isUpgrade;
}
public function setIsUpgrade(bool $value): void
{
$this->isUpgrade = $value;
}
public function isLocal(): bool
{
return $this->isLocal;
}
public function setIsLocal(bool $value): void
{
$this->isLocal = $value;
}
public function getHostPath(): ?string
{
return $this->hostPath;
}
public function setHostPath(?string $value): void
{
$this->hostPath = $value;
}
public function getLockedDatabase(): ?string
{
return $this->lockedDatabase;
}
public function setLockedDatabase(?string $value): void
{
$this->lockedDatabase = $value;
}
}
@@ -0,0 +1,420 @@
<?php
namespace Appwrite\Platform\Installer\Runtime;
use Appwrite\Platform\Installer\Server;
class State
{
private const string PATTERN_DIGITS_ONLY = '/^\d+$/';
private const string PATTERN_HAS_NON_WHITESPACE = '/\S/';
private const string PATTERN_LINE_BREAKS = '/\r\n|\n|\r/';
private const string PATTERN_INSTALL_ID_SANITIZE = '/[^a-zA-Z0-9_-]/';
private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/';
private const int CONFIG_FILE_PERMISSION = 0600;
private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 3600;
private const int PORT_MIN = 1;
private const int PORT_MAX = 65535;
private array $paths;
private bool $bootstrapped = false;
public function __construct(array $paths)
{
$this->paths = $paths;
}
public function buildConfig(array $overrides = [], bool $useEnv = true): Config
{
$cfg = new Config();
$configJson = null;
$decodedOk = false;
if ($useEnv) {
$configJson = getenv('APPWRITE_INSTALLER_CONFIG');
if ($configJson !== false && $configJson !== '') {
$decoded = json_decode($configJson, true);
if (is_array($decoded)) {
$cfg->apply($decoded);
$decodedOk = true;
}
}
}
if ($useEnv && (!$decodedOk)) {
$fileConfig = $this->readConfigFile();
if (is_array($fileConfig)) {
$cfg->apply($fileConfig);
}
}
if ($cfg->isLocal() && empty($cfg->getVars())) {
$envPath = dirname(__DIR__, 5) . '/.env';
if (file_exists($envPath)) {
$envContent = file_get_contents($envPath);
if ($envContent !== false) {
$vars = $this->parseEnvFile($envContent);
if (!empty($vars)) {
$cfg->setVars($vars);
}
}
}
}
$cfg->apply($overrides);
return $cfg;
}
public function applyEnvConfig(Config|array $cfg): void
{
$values = $cfg instanceof Config ? $cfg->toArray() : $cfg;
$json = json_encode($values, JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
return;
}
putenv('APPWRITE_INSTALLER_CONFIG=' . $json);
$this->writeConfigFile($json);
}
private function readConfigFile(): ?array
{
$path = Server::INSTALLER_CONFIG_FILE;
if (!file_exists($path)) {
return null;
}
$contents = file_get_contents($path);
if ($contents === false || $contents === '') {
return null;
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : null;
}
private function writeConfigFile(string $json): void
{
$path = Server::INSTALLER_CONFIG_FILE;
if (@file_put_contents($path, $json) === false) {
return;
}
@chmod($path, self::CONFIG_FILE_PERMISSION);
}
public function ensureBootstrapped(): void
{
if ($this->bootstrapped) {
return;
}
require_once __DIR__ . '/../../../../../app/init.php';
$this->bootstrapped = true;
}
public function sanitizeInstallId($value): string
{
if (!is_string($value)) {
return '';
}
$clean = preg_replace(self::PATTERN_INSTALL_ID_SANITIZE, '', $value);
if (!is_string($clean)) {
return '';
}
return substr($clean, 0, 64);
}
public function hashSensitiveValue(string $value): string
{
$trimmed = trim($value);
if ($trimmed === '') {
return '';
}
return hash('sha256', $trimmed);
}
public function isValidPort($value): bool
{
$string = (string) $value;
if ($string === '' || !preg_match(self::PATTERN_DIGITS_ONLY, $string)) {
return false;
}
$port = (int) $string;
return $port >= self::PORT_MIN && $port <= self::PORT_MAX;
}
public function isValidEmailAddress(string $value): bool
{
return filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
}
public function isValidPassword(string $value): bool
{
return strlen($value) >= 8 && preg_match(self::PATTERN_HAS_NON_WHITESPACE, $value) === 1;
}
public function isValidSecretKey(string $value): bool
{
return $value !== '' && strlen($value) <= 64;
}
public function isValidAccountName(string $value): bool
{
return trim($value) !== '';
}
public function isValidAppDomainInput(string $value): bool
{
$value = trim($value);
if ($value === '') {
return false;
}
$host = $value;
$port = null;
if (str_starts_with($value, '[')) {
if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) {
return false;
}
$host = $matches[1] ?? '';
$port = $matches[2] ?? null;
} else {
$parts = explode(':', $value);
if (count($parts) > 2) {
return false;
}
if (count($parts) === 2) {
[$host, $port] = $parts;
}
}
if ($port !== null && $port !== '' && !$this->isValidPort($port)) {
return false;
}
return $this->isValidAppDomain($host);
}
public function isValidDatabaseAdapter(string $value): bool
{
return in_array($value, ['mongodb', 'mariadb', 'postgresql'], true);
}
public function progressFilePath(string $installId): string
{
return sys_get_temp_dir() . '/appwrite-install-' . $installId . '.json';
}
public function reserveGlobalLock(string $installId): string
{
return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) {
if (!$handle) {
return 'unavailable';
}
if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) {
return 'locked';
}
$payload = [
'installId' => $installId,
'status' => Server::STATUS_IN_PROGRESS,
'updatedAt' => time(),
];
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($payload));
return 'ok';
});
}
public function updateGlobalLock(string $installId, string $status): void
{
$this->withGlobalLock(function ($handle, $lock) use ($installId, $status) {
if (!$handle) {
return;
}
if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) {
return;
}
$payload = [
'installId' => $installId,
'status' => $status,
'updatedAt' => time(),
];
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($payload));
});
}
public function readProgressFile(string $installId): array
{
$path = $this->progressFilePath($installId);
if (!file_exists($path)) {
return [
'installId' => $installId,
'steps' => [],
];
}
$contents = file_get_contents($path);
if ($contents === false) {
return [
'installId' => $installId,
'steps' => [],
];
}
$data = json_decode($contents, true);
if (!is_array($data)) {
return [
'installId' => $installId,
'steps' => [],
];
}
return $data;
}
public function writeProgressFile(string $installId, array $payload): void
{
$data = $this->readProgressFile($installId);
if (!isset($data['steps']) || !is_array($data['steps'])) {
$data['steps'] = [];
}
if (!empty($payload['step'])) {
$data['steps'][$payload['step']] = [
'status' => $payload['status'] ?? Server::STATUS_IN_PROGRESS,
'message' => $payload['message'] ?? '',
'updatedAt' => $payload['updatedAt'] ?? time(),
];
}
if (!empty($payload['status']) && $payload['status'] === Server::STATUS_ERROR) {
$data['error'] = $payload['message'] ?? 'Installation failed';
}
if (isset($payload['details']) && is_array($payload['details'])) {
$data['details'][$payload['step']] = $payload['details'];
}
if (isset($payload['payload']) && is_array($payload['payload'])) {
$data['payload'] = $payload['payload'];
if (!isset($data['startedAt'])) {
$data['startedAt'] = $payload['updatedAt'] ?? time();
}
}
$data['updatedAt'] = $payload['updatedAt'] ?? time();
file_put_contents($this->progressFilePath($installId), json_encode($data), LOCK_EX);
}
private function parseEnvFile(string $contents): array
{
$vars = [];
foreach ((array) preg_split(self::PATTERN_LINE_BREAKS, $contents) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$pos = strpos($line, '=');
if ($pos === false) {
continue;
}
$key = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
if ($key === '') {
continue;
}
$value = $this->stripEnvQuotes($value);
$vars[] = [
'name' => $key,
'default' => $value,
];
}
return $vars;
}
private function stripEnvQuotes(string $value): string
{
if ($value === '') {
return $value;
}
$first = $value[0];
$last = substr($value, -1);
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
$value = substr($value, 1, -1);
}
return $value;
}
private function globalLockPath(): string
{
return Server::INSTALLER_LOCK_FILE;
}
private function isGlobalLockActive(?array $lock): bool
{
if (!$lock || !isset($lock['updatedAt'])) {
return false;
}
if (isset($lock['status']) && in_array($lock['status'], [Server::STATUS_COMPLETED, Server::STATUS_ERROR], true)) {
return false;
}
if (time() - (int) $lock['updatedAt'] > self::GLOBAL_LOCK_TIMEOUT_SECONDS) {
return false;
}
return true;
}
private function withGlobalLock(callable $callback)
{
$path = $this->globalLockPath();
$handle = fopen($path, 'c+');
if ($handle === false) {
return $callback(null, null);
}
if (!flock($handle, LOCK_EX)) {
fclose($handle);
return $callback(null, null);
}
$contents = stream_get_contents($handle);
$lock = null;
if ($contents !== false && $contents !== '') {
$decoded = json_decode($contents, true);
if (is_array($decoded)) {
$lock = $decoded;
}
}
try {
$result = $callback($handle, $lock);
} finally {
fflush($handle);
flock($handle, LOCK_UN);
fclose($handle);
}
return $result;
}
private function isValidAppDomain(string $value): bool
{
if ($value === 'localhost') {
return true;
}
if (filter_var($value, FILTER_VALIDATE_IP) !== false) {
return true;
}
return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
}
+316
View File
@@ -0,0 +1,316 @@
<?php
namespace Appwrite\Platform\Installer;
require_once __DIR__ . '/../../../../vendor/autoload.php';
use Appwrite\Platform\Installer\Http\Installer\Error;
use Appwrite\Platform\Installer\Runtime\State;
use Swoole\Http\Server as SwooleServer;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Http\Adapter\Swoole\Server as SwooleAdapter;
use Utopia\Http\Files;
use Utopia\Http\Http;
use Utopia\Platform\Service;
class Server
{
public const int INSTALLER_WEB_PORT = 20080;
public const string INSTALLER_WEB_HOST = '0.0.0.0';
// temp files for state and config management!
public const string INSTALLER_LOCK_FILE = '/tmp/appwrite-install-lock.json';
public const string INSTALLER_CONFIG_FILE = '/tmp/appwrite-installer-config.json';
public const string INSTALLER_COMPLETE_FILE = '/tmp/appwrite-installer-complete';
public const string STEP_ENV_VARS = 'env-vars';
public const string STEP_CONFIG_FILES = 'config-files';
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
public const string STEP_ACCOUNT_SETUP = 'account-setup';
public const string STATUS_IN_PROGRESS = 'in-progress';
public const string STATUS_COMPLETED = 'completed';
public const string STATUS_ERROR = 'error';
public const string CSRF_COOKIE = 'appwrite-installer-csrf';
public const array INSTALLER_CSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
];
private const string DEFAULT_IMAGE = 'appwrite-dev';
public const string DEFAULT_CONTAINER = 'appwrite-installer';
private State $state;
private array $paths = [];
public function run(): void
{
$this->initPaths();
$this->state = new State($this->paths);
if (PHP_SAPI === 'cli') {
$this->runCli();
return;
}
}
private function initPaths(): void
{
if (!empty($this->paths)) {
return;
}
$root = dirname(__DIR__, 4);
$this->paths = [
'public' => $root . '/public',
'views' => $root . '/app/views/install',
];
}
private function runCli(): void
{
$opts = getopt('', ['upgrade', 'locked-database::', 'docker', 'clean', 'port::', 'ready-file::']);
$cfg = $this->state->buildConfig([], true);
$isDocker = isset($opts['docker']);
if ($isDocker) {
$cfg->setIsLocal(true);
if ($cfg->getHostPath() === null) {
$cwd = getcwd();
if ($cwd !== false) {
$cfg->setHostPath($cwd);
}
}
}
if (isset($opts['upgrade'])) {
$cfg->setIsUpgrade(true);
}
if (!empty($opts['locked-database'])) {
$cfg->setLockedDatabase($opts['locked-database']);
}
$this->state->applyEnvConfig($cfg);
$host = self::INSTALLER_WEB_HOST;
$port = !empty($opts['port']) ? (string) $opts['port'] : (string) self::INSTALLER_WEB_PORT;
$readyFile = !empty($opts['ready-file']) ? (string) $opts['ready-file'] : null;
if (isset($opts['clean'])) {
$this->removeDockerInstallerContainer(self::DEFAULT_CONTAINER);
$this->cleanupWebInstallerFiles();
exit(0);
}
if (isset($opts['docker'])) {
$this->printInstallerUrl($host, $port);
$this->startDockerInstaller($opts);
}
$this->printInstallerUrl($host, $port);
$this->startSwooleServer($host, (int) $port, $readyFile);
}
private function printInstallerUrl(string $host, string $port): void
{
$displayHost = $host === self::INSTALLER_WEB_HOST ? 'localhost' : $host;
$url = "http://$displayHost:$port";
fwrite(STDOUT, "Open $url" . PHP_EOL);
}
private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void
{
// Preload static files into memory
$files = new Files();
$files->load($this->paths['views']);
// Register resources for dependency injection into actions
$config = $this->state->buildConfig();
$paths = $this->paths;
$state = $this->state;
Http::setResource('installerState', fn () => $state);
Http::setResource('installerConfig', fn () => $config);
Http::setResource('installerPaths', fn () => $paths);
// Register routes via Utopia Platform
$platform = new Installer();
$platform->init(Service::TYPE_HTTP);
// Register error handler directly so Http::error() preserves the '*' group
$errorHandler = new Error();
Http::error()
->inject('error')
->inject('response')
->action($errorHandler->action(...));
$adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter {
public function getNativeServer(): SwooleServer
{
return $this->server;
}
};
$nativeServer = $adapter->getNativeServer();
Http::setResource('swooleServer', fn () => $nativeServer);
$nativeServer->on('start', function () use ($nativeServer, $port, $readyFile) {
\Swoole\Process::signal(SIGTERM, fn () => $nativeServer->shutdown());
\Swoole\Process::signal(SIGINT, fn () => $nativeServer->shutdown());
if ($readyFile !== null) {
file_put_contents($readyFile, json_encode(['port' => $port, 'pid' => getmypid()]));
}
});
$adapter->onRequest(function (Request $request, Response $response) use ($files) {
// Serve static files from memory
$uri = $request->getURI();
if ($files->isFileLoaded($uri)) {
$response
->setContentType($files->getFileMimeType($uri))
->send($files->getFileContents($uri));
return;
}
$app = new Http('UTC');
$app->run($request, $response);
});
$adapter->start();
}
private function removeDockerInstallerContainer(string $container): void
{
$name = escapeshellarg($container);
exec("docker rm -f $name >/dev/null 2>&1");
}
private function cleanupWebInstallerFiles(): void
{
$cwd = getcwd();
if ($cwd === false) {
return;
}
$filesToRemove = [
$cwd . '/.env.web-installer',
$cwd . '/docker-compose.web-installer.yml',
];
foreach ($filesToRemove as $file) {
if (file_exists($file)) {
@unlink($file);
}
}
$tempDir = sys_get_temp_dir();
@unlink(self::INSTALLER_LOCK_FILE);
@unlink(self::INSTALLER_CONFIG_FILE);
foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) {
@unlink($file);
}
}
private function dockerImageExists(string $image): bool
{
$result = 1;
exec("docker image inspect " . escapeshellarg($image) . " >/dev/null 2>&1", $output, $result);
return $result === 0;
}
private function buildDockerInstallerImage(string $image): void
{
fwrite(STDOUT, "Building Docker image: {$image}\n");
$buildCommand = 'docker compose build appwrite';
passthru($buildCommand, $status);
if ($status !== 0 || !$this->dockerImageExists($image)) {
fwrite(STDERR, "Failed to build Docker image: $image\n");
fwrite(STDERR, "Try: docker compose build appwrite\n");
exit(1);
}
}
private function ensureLocalInstallerTag(string $source, string $target): void
{
$sourceArg = escapeshellarg($source);
$targetArg = escapeshellarg($target);
exec("docker tag {$sourceArg} {$targetArg}", $tagOutput, $tagStatus);
if ($tagStatus !== 0) {
fwrite(STDERR, "Failed to tag Docker image {$source} as {$target}\n");
exit(1);
}
}
private function startDockerInstaller(array $opts): void
{
$image = self::DEFAULT_IMAGE;
$container = self::DEFAULT_CONTAINER;
if (!$this->dockerImageExists($image)) {
$this->buildDockerInstallerImage($image);
}
$this->ensureLocalInstallerTag($image, 'appwrite/appwrite:local');
$port = (string)self::INSTALLER_WEB_PORT;
$entrypoint = isset($opts['upgrade']) ? 'upgrade' : 'install';
$this->removeDockerInstallerContainer($container);
$root = realpath(dirname(__DIR__, 4));
$volumePath = $root !== false ? $root : (getcwd() ?: '.');
$dockerConfig = $this->state->buildConfig([], false);
$dockerConfig->setIsLocal(true);
$dockerConfig->setHostPath($volumePath);
if (isset($opts['upgrade'])) {
$dockerConfig->setIsUpgrade(true);
}
if (!empty($opts['locked-database'])) {
$dockerConfig->setLockedDatabase($opts['locked-database']);
}
$configJson = json_encode($dockerConfig->toArray(), JSON_UNESCAPED_SLASHES);
if (!is_string($configJson)) {
$configJson = '{}';
}
$args = [
'docker',
'run',
'-i',
'--rm',
'--name', $container,
'-p', "127.0.0.1:$port:" . self::INSTALLER_WEB_PORT,
'--volume', '/var/run/docker.sock:/var/run/docker.sock',
'--volume', "$volumePath:/usr/src/code:rw",
];
$args[] = '-e';
$args[] = 'APPWRITE_INSTALLER_CONFIG=' . $configJson;
$args[] = '--entrypoint=' . $entrypoint;
$args[] = $image;
$command = implode(' ', array_map(escapeshellarg(...), $args));
passthru($command, $status);
exit($status);
}
}
/**
* Run server only on direct CLI execution.
*/
function shouldRunInstallerServer(): bool
{
return PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === realpath(__FILE__);
}
if (shouldRunInstallerServer()) {
$server = new Server();
$server->run();
}
@@ -0,0 +1,26 @@
<?php
namespace Appwrite\Platform\Installer\Services;
use Appwrite\Platform\Installer\Http\Installer\Complete;
use Appwrite\Platform\Installer\Http\Installer\Install;
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
use Appwrite\Platform\Installer\Http\Installer\Status;
use Appwrite\Platform\Installer\Http\Installer\Validate;
use Appwrite\Platform\Installer\Http\Installer\View;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
$this->addAction(View::getName(), new View());
$this->addAction(Status::getName(), new Status());
$this->addAction(Validate::getName(), new Validate());
$this->addAction(Complete::getName(), new Complete());
$this->addAction(Shutdown::getName(), new Shutdown());
$this->addAction(Install::getName(), new Install());
}
}

Some files were not shown because too many files have changed in this diff Show More