diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aa6dbe2bc3..2aeae21655 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -150,8 +150,37 @@ jobs:
- name: Install dependencies
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
+ - name: Cache PHPStan result cache
+ uses: actions/cache@v4
+ with:
+ path: .phpstan-cache
+ key: phpstan-${{ github.sha }}
+ restore-keys: |
+ phpstan-
+
- name: Run PHPStan
- run: composer analyze
+ run: composer analyze -- --no-progress
+
+ specs:
+ name: Checks / Specs
+ 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'
+ extensions: swoole
+ tools: composer:v2
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --ignore-platform-reqs
+
+ - name: Generate specs
+ run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no
locale:
name: Checks / Locale
@@ -451,6 +480,10 @@ jobs:
_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' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
@@ -525,6 +558,10 @@ jobs:
_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' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
@@ -582,6 +619,10 @@ jobs:
env:
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
diff --git a/.gitignore b/.gitignore
index d6e138a382..6846e19aa7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ appwrite.config.json
/app/config/specs/
/docs/examples/
.phpunit.cache
+.phpstan-cache
playwright-report
test-results
docker-compose.web-installer.yml
diff --git a/AGENTS.md b/AGENTS.md
index bb24d9f4fe..4d11ff0ee3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,107 +1,120 @@
-# AGENTS.md
+# Appwrite
-Appwrite is an end-to-end backend server for web, mobile, native, and backend apps. This guide provides context and instructions for AI coding agents working on the Appwrite codebase.
+Self-hosted Backend-as-a-Service platform. Hybrid monolithic-microservice architecture built with PHP 8.3+ on Swoole, delivered as Docker containers.
-## Project Overview
+## Commands
-Appwrite is a self-hosted Backend-as-a-Service (BaaS) platform that provides developers with a set of APIs and tools to build secure, scalable applications. The project uses a hybrid monolithic-microservice architecture built with PHP, running on Swoole for high performance.
+| Command | Purpose |
+|---------|---------|
+| `docker compose up -d --force-recreate --build` | Build and start all services |
+| `docker compose exec appwrite test tests/e2e/Services/[Service]` | Run E2E tests for a service |
+| `docker compose exec appwrite test tests/e2e/Services/[Service] --filter=[Method]` | Run a single test method |
+| `docker compose exec appwrite test tests/unit/` | Run unit tests |
+| `composer format` | Auto-format code (Pint, PSR-12) |
+| `composer format ` | Format a specific file |
+| `composer lint ` | Check formatting of a file |
+| `composer analyze` | Static analysis (PHPStan level 3) |
+| `composer check` | Same as `analyze` |
-**Key Technologies:**
-- **Backend:** PHP 8.3+, Swoole
-- **Libraries:** Utopia PHP
-- **Database:** MariaDB, Redis
-- **Cache:** Redis
-- **Queue:** Redis
-- **Containers:** Docker
+## Stack
-## Development Commands
+- PHP 8.3+, Swoole 6.x (async runtime, replaces PHP-FPM)
+- Utopia PHP framework (HTTP routing, CLI, DI, queue)
+- MongoDB (default), MariaDB, MySQL, PostgreSQL (adapters via utopia-php/database)
+- Redis (cache, queue, pub/sub)
+- Docker + Traefik (reverse proxy)
+- PHPUnit 12, Pint (PSR-12), PHPStan level 3
-```bash
-# Run Appwrite
-docker compose up -d --force-recreate --build
+## Project layout
-# Run specific test
-docker compose exec appwrite test /usr/src/code/tests/e2e/Services/[ServiceName] --filter=[FunctionName]
+- **src/Appwrite/Platform/Modules/** -- feature modules (Account, Avatars, Compute, Console, Databases, Functions, Health, Project, Projects, Proxy, Sites, Storage, Teams, Tokens, VCS, Webhooks)
+- **src/Appwrite/Platform/Workers/** -- background job workers
+- **src/Appwrite/Platform/Tasks/** -- CLI tasks
+- **app/init.php** -- bootstrap (registers services, resources, listeners)
+- **app/init/** -- configs, constants, locales, models, registers, resources, span, database filters/formats
+- **bin/** -- CLI entry points: `worker-*` (14 workers), `schedule-*`, `queue-*`, plus `doctor`, `install`, `migrate`, `realtime`, `upgrade`, `ssl`, `vars`, `maintenance`, `interval`, `specs`, `sdks`, etc.
+- **tests/e2e/** -- end-to-end tests per service
+- **tests/unit/** -- unit tests
+- **public/** -- static assets and generated SDKs
-# Format code
-composer format
+## Module structure
+
+Each module under `src/Appwrite/Platform/Modules/{Name}/` contains:
+
+```
+Module.php -- registers all services for the module
+Services/Http.php -- registers HTTP endpoints
+Services/Workers.php -- registers background workers
+Services/Tasks.php -- registers CLI tasks
+Http/{Service}/ -- endpoint actions (Create.php, Get.php, Update.php, Delete.php, XList.php)
+Workers/ -- worker implementations
+Tasks/ -- CLI task implementations
```
-## Code Style Guidelines
+HTTP endpoint nesting reflects the URL path. Sub-resources get subdirectories. For example, within the Functions module:
+`Http/Deployments/Template/Create.php` -> `POST /v1/functions/:functionId/deployments/template`
-- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard
-- Use PSR-4 autoloading
-- Strict type declarations where applicable
-- Comprehensive PHPDoc comments
+File names in Http directories must only be `Get.php`, `Create.php`, `Update.php`, `Delete.php`, or `XList.php`. For non-CRUD operations, model the endpoint as a property update. For example, updating a team membership status lives at `Teams/Http/Memberships/Status/Update.php` (`PATCH /v1/teams/:teamId/memberships/:membershipId/status`).
-### Naming Conventions
+Register new modules in `src/Appwrite/Platform/Appwrite.php`. Detailed module guide: `src/Appwrite/Platform/AGENTS.md`.
-#### `resourceType` Naming Rule
+## Action pattern (HTTP endpoints)
-When a collection has a combination of `resourceType`, `resourceId`, and/or `resourceInternalId`, the value of `resourceType` MUST always be **plural** - for example: `functions`, `sites`, `deployments`.
-
-Examples:
```php
-'resourceType' => 'functions'
-'resourceType' => 'sites'
-'resourceType' => 'deployments'
+class Create extends Action
+{
+ public static function getName(): string { return 'createTeam'; }
+
+ public function __construct()
+ {
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/teams')
+ ->desc('Create team')
+ ->groups(['api', 'teams'])
+ ->label('event', 'teams.[teamId].create')
+ ->label('scope', 'teams.write')
+ ->param('teamId', '', new CustomId(), 'Team ID.')
+ ->param('name', null, new Text(128), 'Team name.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $teamId,
+ string $name,
+ Response $response,
+ Database $dbForProject,
+ Event $queueForEvents,
+ ): void {
+ // implementation
+ }
+}
```
-## Performance Patterns
+Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `$user`, `$project`, `$queueForEvents`, `$queueForMails`, `$queueForDeletes`.
-### Document Update Optimization
+## Conventions
-When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`.
+- PSR-12 formatting enforced by Pint. PSR-4 autoloading.
+- `resourceType` values are always **plural**: `'functions'`, `'sites'`, `'deployments'`.
+- When updating documents, pass only changed attributes as a sparse Document:
+ ```php
+ // correct
+ $dbForProject->updateDocument('users', $user->getId(), new Document([
+ 'name' => $name,
+ ]));
+ // incorrect -- passing full document is inefficient
+ $user->setAttribute('name', $name);
+ $dbForProject->updateDocument('users', $user->getId(), $user);
+ ```
+ Exceptions: migrations, `array_merge()` with `getArrayCopy()`, updates where nearly all attributes change, complex nested relationship logic requiring full document state.
+- Avoid introducing dependencies outside the `utopia-php` ecosystem.
+- Never hardcode credentials -- use environment variables.
+- Code changes may require container restart. No central log location -- check relevant containers.
-**Correct Pattern:**
-```php
-// Good: Pass only changed attributes directly
-$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
- 'name' => $name,
- 'email' => $email,
-]));
-```
+## Cross-repo context
-**Incorrect Pattern:**
-```php
-$user->setAttribute('name', $name);
-$user->setAttribute('email', $email);
-
-// Bad: Passing full document is inefficient
-$user = $dbForProject->updateDocument('users', $user->getId(), $user);
-```
-
-**Exceptions:**
-- Migration files (need full document updates by design)
-- Cases already using `array_merge()` with `getArrayCopy()`
-- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document)
-- Complex nested relationship logic where full document state is required
-
-## Security Considerations
-
-### Critical Security Practices
-
-- **Never hardcode credentials** - Use environment variables
-- **Rate limiting** - Respect abuse prevention mechanisms
-
-## Dependencies
-
-Avoid introducing new dependencies other than utopia-php.
-
-## Adding new endpoints
-
-When adding new endpoints, make sure to use modules and follow its patterns. Find instruction in [Modules AGENTS.md](src/Appwrite/Platform/AGENTS.md) file.
-
-## Pull Request Guidelines
-### Before Submitting
-
-- Run `composer format`
-- Update documentation if adding features
-- Add/update tests for your changes
-- Check that Docker build succeeds
-`docs/specs/authentication.drawio.svg`
-
-## Known Issues and Gotchas
-
-- **Hot Reload:** Code changes require container restart in some cases
-- **Logging:** There is no central place for logs, so when debugging, ensure to check all possibly relevant containers
+Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console.
diff --git a/README.md b/README.md
index 457863d236..88d527f060 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,28 @@
-> We just announced DB operators for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-db-operators)
-
-> Appwrite Cloud is now Generally Available - [Learn more](https://appwrite.io/cloud-ga)
-
-> [Get started with Appwrite](https://apwr.dev/appcloud)
+
-
-
-
- Appwrite is a best-in-class, developer-first platform that gives builders everything they need to create scalable, stable, and production-ready software, fast.
+
Appwrite
+ Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.
-
-
-[](https://appwrite.io/company/careers)
-[](https://hacktoberfest.appwrite.io)
-[](https://appwrite.io/discord?r=Github)
-[](https://github.com/appwrite/appwrite/actions)
-[](https://twitter.com/appwrite)
-
-
-
-
+[](https://appwrite.io/discord)
+[](https://x.com/appwrite)
+[](https://cloud.appwrite.io)
English | [简体中文](README-CN.md)
-Appwrite is an end-to-end platform for building Web, Mobile, Native, or Backend apps, packaged as a set of Docker microservices. It includes both a backend server and a fully integrated hosting solution for deploying static and server-side rendered frontends. Appwrite abstracts the complexity and repetitiveness required to build modern apps from scratch and allows you to build secure, full-stack applications faster.
+Appwrite is an open-source development platform for building web, mobile, and AI applications. It brings together backend infrastructure and web hosting in one place, so teams can build, ship, and scale without stitching together a fragmented stack. Appwrite is available as a managed cloud platform and can also be self-hosted on infrastructure you control.
-Using Appwrite, you can easily integrate your app with user authentication and multiple sign-in methods, a database for storing and querying users and team data, storage and file management, image manipulation, Cloud Functions, messaging, and [more services](https://appwrite.io/docs).
+With Appwrite, you can add authentication, databases, storage, functions, messaging, realtime capabilities, and integrated web app hosting through Sites. It is designed to reduce the repetitive backend work required to launch modern products while giving developers secure primitives and flexible APIs to build production-ready applications faster.
-
-
-Find out more at: [https://appwrite.io](https://appwrite.io).
+Find out more at [https://appwrite.io](https://appwrite.io).
Table of Contents:
+- [Products](#products)
- [Installation \& Setup](#installation--setup)
- [Self-Hosting](#self-hosting)
- [Unix](#unix)
@@ -47,17 +32,31 @@ Table of Contents:
- [Upgrade from an Older Version](#upgrade-from-an-older-version)
- [One-Click Setups](#one-click-setups)
- [Getting Started](#getting-started)
- - [Products](#products)
- [SDKs](#sdks)
- [Client](#client)
- [Server](#server)
- - [Community](#community)
- [Architecture](#architecture)
- [Contributing](#contributing)
- [Security](#security)
- [Follow Us](#follow-us)
- [License](#license)
+
+## Products
+
+- **[Appwrite Auth](https://appwrite.io/docs/products/auth)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows.
+
+- **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data.
+
+- **[Appwrite Storage](https://appwrite.io/docs/products/storage)** - Secure file storage with support for uploads, downloads, encryption, compression, and file transformations for media and assets.
+
+- **[Appwrite Functions](https://appwrite.io/docs/products/functions)** - Serverless compute platform to run custom backend logic in isolated runtimes, triggered by events or scheduled jobs.15 runtimes supported.
+
+- **[Appwrite Messaging](https://appwrite.io/docs/products/messaging)** - Multi-channel messaging system for sending emails, SMS, and push notifications to users for engagement, alerts, and transactional workflows.
+
+- **[Appwrite Sites](https://appwrite.io/docs/products/sites)** - Integrated hosting platform to deploy and scale web applications with support for custom domains, SSR, and seamless backend integration. Git integration and previews are supported.
+
+
## Installation & Setup
The easiest way to get started with Appwrite is by [signing up for Appwrite Cloud](https://cloud.appwrite.io/). While Appwrite Cloud is in public beta, you can build with Appwrite completely free, and we won't collect your credit card information.
@@ -72,6 +71,7 @@ Before running the installation command, make sure you have [Docker](https://www
```bash
docker run -it --rm \
+ --publish 20080:20080 \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
@@ -84,6 +84,7 @@ docker run -it --rm \
```cmd
docker run -it --rm ^
+ --publish 20080:20080 ^
--volume //var/run/docker.sock:/var/run/docker.sock ^
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
--entrypoint="install" ^
@@ -94,6 +95,7 @@ docker run -it --rm ^
```powershell
docker run -it --rm `
+ --publish 20080:20080 `
--volume /var/run/docker.sock:/var/run/docker.sock `
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
--entrypoint="install" `
@@ -165,51 +167,29 @@ Getting started with Appwrite is as easy as creating a new project, choosing you
| | [Quick start for Kotlin](https://appwrite.io/docs/quick-starts/kotlin) |
| | [Quick start for Swift](https://appwrite.io/docs/quick-starts/swift) |
-### Products
-
-- [**Account**](https://appwrite.io/docs/references/cloud/client-web/account) - Manage current user authentication and account. Track and manage the user sessions, devices, sign-in methods, and security logs.
-- [**Users**](https://appwrite.io/docs/server/users) - Manage and list all project users when building backend integrations with Server SDKs.
-- [**Teams**](https://appwrite.io/docs/references/cloud/client-web/teams) - Manage and group users in teams. Manage memberships, invites, and user roles within a team.
-- [**Databases**](https://appwrite.io/docs/references/cloud/client-web/databases) - Manage databases, collections, and documents. Read, create, update, and delete documents and filter lists of document collections using advanced filters.
-- [**Storage**](https://appwrite.io/docs/references/cloud/client-web/storage) - Manage storage files. Read, create, delete, and preview files. Manipulate the preview of your files to perfectly fit your app. All files are scanned by ClamAV and stored in a secure and encrypted way.
-- [**Functions**](https://appwrite.io/docs/references/cloud/server-nodejs/functions) - Customize your Appwrite project by executing your custom code in a secure, isolated environment. You can trigger your code on any Appwrite system event either manually or using a CRON schedule.
-- [**Messaging**](https://appwrite.io/docs/references/cloud/client-web/messaging) - Communicate with your users through push notifications, emails, and SMS text messages using Appwrite Messaging.
-- [**Realtime**](https://appwrite.io/docs/realtime) - Listen to real-time events for any of your Appwrite services including users, storage, functions, databases, and more.
-- [**Locale**](https://appwrite.io/docs/references/cloud/client-web/locale) - Track your user's location and manage your app locale-based data.
-- [**Avatars**](https://appwrite.io/docs/references/cloud/client-web/avatars) - Manage your users' avatars, countries' flags, browser icons, and credit card symbols. Generate QR codes from links or plaintext strings.
-- [**MCP**](https://appwrite.io/docs/tooling/mcp) - Use Appwrite's Model Context Protocol (MCP) server to allow LLMs and AI tools like Claude Desktop, Cursor, and Windsurf Editor to directly interact with your Appwrite project through natural language.
-- [**Sites**](https://appwrite.io/docs/products/sites) - Develop, deploy, and scale your web applications directly from Appwrite, alongside your backend.
-
-For the complete API documentation, visit [https://appwrite.io/docs](https://appwrite.io/docs). For more tutorials, news and announcements check out our [blog](https://medium.com/appwrite-io) and [Discord Server](https://discord.gg/GSeTUeA).
-
### SDKs
Below is a list of currently supported platforms and languages. If you would like to help us add support to your platform of choice, you can go over to our [SDK Generator](https://github.com/appwrite/sdk-generator) project and view our [contribution guide](https://github.com/appwrite/sdk-generator/blob/master/CONTRIBUTING.md).
#### Client
-- :white_check_mark: [Web](https://github.com/appwrite/sdk-for-web) (Maintained by the Appwrite Team)
-- :white_check_mark: [Flutter](https://github.com/appwrite/sdk-for-flutter) (Maintained by the Appwrite Team)
-- :white_check_mark: [Apple](https://github.com/appwrite/sdk-for-apple) (Maintained by the Appwrite Team)
-- :white_check_mark: [Android](https://github.com/appwrite/sdk-for-android) (Maintained by the Appwrite Team)
-- :white_check_mark: [React Native](https://github.com/appwrite/sdk-for-react-native) - **Beta** (Maintained by the Appwrite Team)
+- :white_check_mark: [Web](https://github.com/appwrite/sdk-for-web)
+- :white_check_mark: [Flutter](https://github.com/appwrite/sdk-for-flutter)
+- :white_check_mark: [Apple](https://github.com/appwrite/sdk-for-apple)
+- :white_check_mark: [Android](https://github.com/appwrite/sdk-for-android)
+- :white_check_mark: [React Native](https://github.com/appwrite/sdk-for-react-native)
#### Server
-- :white_check_mark: [NodeJS](https://github.com/appwrite/sdk-for-node) (Maintained by the Appwrite Team)
-- :white_check_mark: [PHP](https://github.com/appwrite/sdk-for-php) (Maintained by the Appwrite Team)
-- :white_check_mark: [Dart](https://github.com/appwrite/sdk-for-dart) (Maintained by the Appwrite Team)
-- :white_check_mark: [Deno](https://github.com/appwrite/sdk-for-deno) (Maintained by the Appwrite Team)
-- :white_check_mark: [Ruby](https://github.com/appwrite/sdk-for-ruby) (Maintained by the Appwrite Team)
-- :white_check_mark: [Python](https://github.com/appwrite/sdk-for-python) (Maintained by the Appwrite Team)
-- :white_check_mark: [Kotlin](https://github.com/appwrite/sdk-for-kotlin) (Maintained by the Appwrite Team)
-- :white_check_mark: [Swift](https://github.com/appwrite/sdk-for-swift) (Maintained by the Appwrite Team)
-- :white_check_mark: [.NET](https://github.com/appwrite/sdk-for-dotnet) - **Beta** (Maintained by the Appwrite Team)
-
-#### Community
-
-- :white_check_mark: [Appcelerator Titanium](https://github.com/m1ga/ti.appwrite) (Maintained by [Michael Gangolf](https://github.com/m1ga/))
-- :white_check_mark: [Godot Engine](https://github.com/GodotNuts/appwrite-sdk) (Maintained by [fenix-hub @GodotNuts](https://github.com/fenix-hub))
+- :white_check_mark: [NodeJS](https://github.com/appwrite/sdk-for-node)
+- :white_check_mark: [PHP](https://github.com/appwrite/sdk-for-php)
+- :white_check_mark: [Dart](https://github.com/appwrite/sdk-for-dart)
+- :white_check_mark: [Deno](https://github.com/appwrite/sdk-for-deno)
+- :white_check_mark: [Ruby](https://github.com/appwrite/sdk-for-ruby)
+- :white_check_mark: [Python](https://github.com/appwrite/sdk-for-python)
+- :white_check_mark: [Kotlin](https://github.com/appwrite/sdk-for-kotlin)
+- :white_check_mark: [Swift](https://github.com/appwrite/sdk-for-swift)
+- :white_check_mark: [.NET](https://github.com/appwrite/sdk-for-dotnet)
Looking for more SDKs? - Help us by contributing a pull request to our [SDK Generator](https://github.com/appwrite/sdk-generator)!
diff --git a/app/cli.php b/app/cli.php
index b8721320be..458df2d642 100644
--- a/app/cli.php
+++ b/app/cli.php
@@ -18,13 +18,15 @@ use Swoole\Timer;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
+use Utopia\CLI\Adapters\Generic;
+use Utopia\CLI\CLI;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
-use Utopia\DI\Dependency;
+use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Platform\Service;
@@ -47,7 +49,7 @@ require_once __DIR__ . '/controllers/general.php';
global $register;
$platform = new Appwrite();
-$args = $platform->getEnv('argv');
+$args = $_SERVER['argv'] ?? [];
\array_shift($args);
if (! isset($args[0])) {
@@ -56,21 +58,15 @@ if (! isset($args[0])) {
}
$taskName = $args[0];
+$container = new Container();
+$cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container);
+
+$platform->setCli($cli);
$platform->init(Service::TYPE_TASK);
-$cli = $platform->getCli();
-$setResource = function (string $name, callable $callback, array $injections = []) use ($cli) {
- $dependency = new Dependency();
- $dependency->setName($name)->setCallback($callback);
- foreach ($injections as $injection) {
- $dependency->inject($injection);
- }
- $cli->setResource($dependency);
-};
+$container->set('register', fn () => $register, []);
-$setResource('register', fn () => $register, []);
-
-$setResource('cache', function ($pools) {
+$container->set('cache', function ($pools) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -81,18 +77,18 @@ $setResource('cache', function ($pools) {
return new Cache(new Sharding($adapters));
}, ['pools']);
-$setResource('pools', function (Registry $register) {
+$container->set('pools', function (Registry $register) {
return $register->get('pools');
}, ['register']);
-$setResource('authorization', function () {
+$container->set('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
}, []);
-$setResource('dbForPlatform', function ($pools, $cache, $authorization) {
+$container->set('dbForPlatform', function ($pools, $cache, $authorization) {
$sleep = 3;
$maxAttempts = 5;
$attempts = 0;
@@ -135,17 +131,17 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
return $dbForPlatform;
}, ['pools', 'cache', 'authorization']);
-$setResource('console', function () {
+$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
-$setResource(
+$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false,
[]
);
-$setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) {
+$container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) {
$databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) {
@@ -207,10 +203,10 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
-$setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
- return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
+ return function (?Document $project = null) use ($pools, $cache, &$database, $authorization) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($project->getSequence());
return $database;
@@ -235,41 +231,41 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
return $database;
};
}, ['pools', 'cache', 'authorization']);
-$setResource('publisher', function (Group $pools) {
+$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
-$setResource('publisherDatabases', function (BrokerPool $publisher) {
+$container->set('publisherDatabases', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('publisherFunctions', function (BrokerPool $publisher) {
+$container->set('publisherFunctions', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('publisherMigrations', function (BrokerPool $publisher) {
+$container->set('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('publisherMessaging', function (BrokerPool $publisher) {
+$container->set('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('usage', function () {
+$container->set('usage', function () {
return new UsageContext();
}, []);
-$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+$container->set('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) {
+$container->set('queueForStatsResources', function (Publisher $publisher) {
return new StatsResources($publisher);
}, ['publisher']);
-$setResource('queueForFunctions', function (Publisher $publisher) {
+$container->set('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
-$setResource('queueForDeletes', function (Publisher $publisher) {
+$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
-$setResource('queueForCertificates', function (Publisher $publisher) {
+$container->set('queueForCertificates', function (Publisher $publisher) {
return new Certificate($publisher);
}, ['publisher']);
-$setResource('logError', function (Registry $register) {
+$container->set('logError', function (Registry $register) {
return function (Throwable $error, string $namespace, string $action) use ($register) {
Console::error('[Error] Timestamp: ' . date('c', time()));
Console::error('[Error] Type: ' . get_class($error));
@@ -321,25 +317,28 @@ $setResource('logError', function (Registry $register) {
};
}, ['register']);
-$setResource('executor', fn () => new Executor(), []);
+$container->set('executor', fn () => new Executor(), []);
-$setResource('bus', function (Registry $register) use ($cli) {
- return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name));
+$container->set('bus', function (Registry $register) use ($container) {
+ return $register->get('bus')->setResolver(fn (string $name) => $container->get($name));
}, ['register']);
-$setResource('telemetry', fn () => new NoTelemetry(), []);
+$container->set('telemetry', fn () => new NoTelemetry(), []);
+
+$exitCode = 0;
$cli
->error()
->inject('error')
->inject('logError')
- ->action(function (Throwable $error, callable $logError) use ($taskName) {
+ ->action(function (Throwable $error, callable $logError) use ($taskName, &$exitCode) {
call_user_func_array($logError, [
$error,
'Task',
$taskName,
]);
+ $exitCode = 1;
Timer::clearAll();
});
@@ -348,3 +347,4 @@ $cli->shutdown()->action(fn () => Timer::clearAll());
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
require_once __DIR__ . '/init/span.php';
run($cli->run(...));
+Console::exit($exitCode);
diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php
index 84964ac96a..6195c11724 100644
--- a/app/config/collections/platform.php
+++ b/app/config/collections/platform.php
@@ -594,7 +594,7 @@ $platformCollections = [
'filters' => [],
],
[
- '$id' => ID::custom('key'),
+ '$id' => ID::custom('key'), // For app platforms
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
@@ -605,7 +605,7 @@ $platformCollections = [
'filters' => [],
],
[
- '$id' => ID::custom('store'),
+ '$id' => ID::custom('store'), // Unused at the moment
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
@@ -616,7 +616,7 @@ $platformCollections = [
'filters' => [],
],
[
- '$id' => ID::custom('hostname'),
+ '$id' => ID::custom('hostname'), // For web platforms
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php
index b41e8f0fd5..9568c59369 100644
--- a/app/config/collections/projects.php
+++ b/app/config/collections/projects.php
@@ -64,7 +64,7 @@ return [
[
'$id' => ID::custom('database'),
'type' => Database::VAR_STRING,
- 'size' => 128,
+ 'size' => 2000,
'required' => false,
'signed' => true,
'array' => false,
diff --git a/app/config/console.php b/app/config/console.php
index 24de8a8cbb..0b0d6c5881 100644
--- a/app/config/console.php
+++ b/app/config/console.php
@@ -39,6 +39,10 @@ $console = [
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
'duration' => TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds
'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled',
+ // For email configuration, false means feature is disabled; false means these emails are allowed during sign-ups
+ 'disposableEmails' => false,
+ 'canonicalEmails' => false,
+ 'freeEmails' => false,
'invalidateSessions' => true
],
'authWhitelistEmails' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [],
diff --git a/app/config/errors.php b/app/config/errors.php
index ec2593d207..4190c6e277 100644
--- a/app/config/errors.php
+++ b/app/config/errors.php
@@ -226,6 +226,21 @@ return [
'description' => 'A user with the same email already exists in the current project.',
'code' => 409,
],
+ Exception::USER_EMAIL_DISPOSABLE => [
+ 'name' => Exception::USER_EMAIL_DISPOSABLE,
+ 'description' => 'Disposable email addresses are not allowed. Please use a permanent email address.',
+ 'code' => 400,
+ ],
+ Exception::USER_EMAIL_FREE => [
+ 'name' => Exception::USER_EMAIL_FREE,
+ 'description' => 'Free email addresses are not allowed. Please use a business or custom-domain email address.',
+ 'code' => 400,
+ ],
+ Exception::USER_EMAIL_NOT_CANONICAL => [
+ 'name' => Exception::USER_EMAIL_NOT_CANONICAL,
+ 'description' => 'This email address must already be in its canonical form. Please remove aliases, tags, or provider-specific variations and try again.',
+ 'code' => 400,
+ ],
Exception::USER_PASSWORD_MISMATCH => [
'name' => Exception::USER_PASSWORD_MISMATCH,
'description' => 'Passwords do not match. Please check the password and confirm password.',
@@ -1164,6 +1179,16 @@ return [
'description' => 'Platform with the requested ID could not be found.',
'code' => 404,
],
+ Exception::PLATFORM_METHOD_UNSUPPORTED => [
+ 'name' => Exception::PLATFORM_METHOD_UNSUPPORTED,
+ 'description' => 'The requested platform has invalid type. Please use corresponding update method for the platform type.',
+ 'code' => 400,
+ ],
+ Exception::PLATFORM_ALREADY_EXISTS => [
+ 'name' => Exception::PLATFORM_ALREADY_EXISTS,
+ 'description' => 'Platform with the same ID already exists in this project. Try again with a different ID.',
+ 'code' => 409,
+ ],
Exception::VARIABLE_NOT_FOUND => [
'name' => Exception::VARIABLE_NOT_FOUND,
'description' => 'Variable with the requested ID could not be found.',
diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json
index 8e59c40123..3d667a36ad 100644
--- a/app/config/locale/translations/en.json
+++ b/app/config/locale/translations/en.json
@@ -57,21 +57,21 @@
"emails.recovery.thanks": "Thanks,",
"emails.recovery.buttonText": "Reset password",
"emails.recovery.signature": "{{project}} team",
- "emails.csvExport.success.subject": "Your CSV export is ready",
- "emails.csvExport.success.preview": "Your data export has been completed successfully.",
- "emails.csvExport.success.hello": "Hello {{user}},",
- "emails.csvExport.success.body": "Your CSV export is ready to download. Click the button below to download your data export.",
- "emails.csvExport.success.footer": "This download link will expire in 1 hour.",
- "emails.csvExport.success.thanks": "Thanks,",
- "emails.csvExport.success.buttonText": "Download CSV",
- "emails.csvExport.success.signature": "Appwrite team",
- "emails.csvExport.failure.subject": "Your CSV export failed - file too large",
- "emails.csvExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
- "emails.csvExport.failure.hello": "Hello {{user}},",
- "emails.csvExport.failure.body": "Your CSV export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
- "emails.csvExport.failure.footer": "If you have any questions, please contact our support team.",
- "emails.csvExport.failure.thanks": "Thanks,",
- "emails.csvExport.failure.signature": "{{project}} team",
+ "emails.dataExport.success.subject": "Your {{type}} export is ready",
+ "emails.dataExport.success.preview": "Your data export has been completed successfully.",
+ "emails.dataExport.success.hello": "Hello {{user}},",
+ "emails.dataExport.success.body": "Your {{type}} export is ready to download. Click the button below to download your data export.",
+ "emails.dataExport.success.footer": "This download link will expire in 1 hour.",
+ "emails.dataExport.success.thanks": "Thanks,",
+ "emails.dataExport.success.buttonText": "Download {{type}}",
+ "emails.dataExport.success.signature": "Appwrite team",
+ "emails.dataExport.failure.subject": "Your {{type}} export failed - file too large",
+ "emails.dataExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
+ "emails.dataExport.failure.hello": "Hello {{user}},",
+ "emails.dataExport.failure.body": "Your {{type}} export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
+ "emails.dataExport.failure.footer": "If you have any questions, please contact our support team.",
+ "emails.dataExport.failure.thanks": "Thanks,",
+ "emails.dataExport.failure.signature": "{{project}} team",
"emails.invitation.subject": "Invitation to {{team}} Team at {{project}}",
"emails.invitation.preview": "{{owner}} invited you to join {{team}} at {{project}}",
"emails.invitation.hello": "Hello {{user}},",
diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php
index e6acd08c54..cda6459519 100644
--- a/app/config/oAuthProviders.php
+++ b/app/config/oAuthProviders.php
@@ -376,6 +376,17 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress',
],
+ 'x' => [
+ 'name' => 'X',
+ 'developers' => 'https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code',
+ 'icon' => 'icon-twitter',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => false,
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\X',
+ ],
'yahoo' => [
'name' => 'Yahoo',
'developers' => 'https://developer.yahoo.com/oauth2/guide/flows_authcode/',
diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php
index 8d85662652..228a1437f2 100644
--- a/app/config/scopes/organization.php
+++ b/app/config/scopes/organization.php
@@ -3,13 +3,6 @@
// List of scopes for organization (teams) API keys
return [
- "platforms.read" => [
- "description" => 'Access to read project\'s platforms',
- ],
- "platforms.write" => [
- "description" =>
- 'Access to create, update, and delete project\'s platforms',
- ],
"projects.read" => [
"description" => 'Access to read organization\'s projects',
],
@@ -17,13 +10,6 @@ return [
"description" =>
"Access to create, update, and delete projects in organization",
],
- "keys.read" => [
- "description" => 'Access to read project\'s API keys',
- ],
- "keys.write" => [
- "description" =>
- "Access to create, update, and delete project\'s API keys",
- ],
"devKeys.read" => [
"description" => 'Access to read project\'s development keys',
],
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index f5d8461aff..6c7f75c08e 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -188,4 +188,20 @@ return [ // List of publicly visible scopes
"description" =>
"Access to update project\'s information",
],
+ "keys.read" => [
+ "description" =>
+ "Access to read project\'s keys",
+ ],
+ "keys.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s keys",
+ ],
+ "platforms.read" => [
+ "description" =>
+ "Access to read project\'s platforms",
+ ],
+ "platforms.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s platforms",
+ ],
];
diff --git a/app/config/sdks.php b/app/config/sdks.php
index 13fb444216..47dc8845b6 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -250,26 +250,16 @@ return [
],
],
],
- [
- 'key' => 'markdown',
- 'name' => 'Markdown',
- 'version' => '0.3.0',
- 'url' => 'https://github.com/appwrite/sdk-for-md.git',
- 'package' => 'https://www.npmjs.com/package/@appwrite.io/docs',
- 'enabled' => true,
- 'beta' => false,
- 'dev' => false,
- 'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CONSOLE,
- 'prism' => 'markdown',
- 'source' => \realpath(__DIR__ . '/../sdks/console-md'),
- 'gitUrl' => 'git@github.com:appwrite/sdk-for-md.git',
- 'gitRepoName' => 'sdk-for-md',
- 'gitUserName' => 'appwrite',
- 'gitBranch' => 'dev',
- 'repoBranch' => 'main',
- 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/md/CHANGELOG.md'),
- ],
+ ],
+ ],
+
+ APP_SDK_PLATFORM_STATIC => [
+ 'key' => APP_SDK_PLATFORM_STATIC,
+ 'name' => 'Static',
+ 'description' => 'SDK artifacts for Appwrite integrations that do not require a generated platform API specification.',
+ 'enabled' => true,
+ 'beta' => false,
+ 'sdks' => [
[
'key' => 'agent-skills',
'name' => 'AgentSkills',
@@ -279,9 +269,10 @@ return [
'beta' => false,
'dev' => false,
'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CONSOLE,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
'prism' => 'agent-skills',
- 'source' => \realpath(__DIR__ . '/../sdks/console-agent-skills'),
+ 'source' => \realpath(__DIR__ . '/../sdks/static-agent-skills'),
'gitUrl' => 'git@github.com:appwrite/agent-skills.git',
'gitRepoName' => 'agent-skills',
'gitUserName' => 'appwrite',
@@ -298,9 +289,10 @@ return [
'beta' => false,
'dev' => false,
'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CONSOLE,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
'prism' => 'cursor-plugin',
- 'source' => \realpath(__DIR__ . '/../sdks/console-cursor-plugin'),
+ 'source' => \realpath(__DIR__ . '/../sdks/static-cursor-plugin'),
'gitUrl' => 'git@github.com:appwrite/cursor-plugin.git',
'gitRepoName' => 'cursor-plugin',
'gitUserName' => 'appwrite',
diff --git a/app/config/templates/function.php b/app/config/templates/function.php
index 6bdfb5ab80..df3a569705 100644
--- a/app/config/templates/function.php
+++ b/app/config/templates/function.php
@@ -31,9 +31,6 @@ class FunctionUseCases
public const DEV_TOOLS = 'dev-tools';
public const AUTH = 'auth';
- /**
- * @var array
- */
public static function getAll(): array
{
return [
diff --git a/app/config/templates/site.php b/app/config/templates/site.php
index 50a9fb8d5d..26f8e39817 100644
--- a/app/config/templates/site.php
+++ b/app/config/templates/site.php
@@ -25,9 +25,6 @@ class SiteUseCases
public const FORMS = 'forms';
public const DASHBOARD = 'dashboard';
- /**
- * @var array
- */
public static function getAll(): array
{
return [
@@ -252,7 +249,7 @@ return [
'frameworks' => [
getFramework('VITE', [
'providerRootDirectory' => './vite/vitepress',
- 'outputDirectory' => '404.html',
+ 'fallbackFile' => '404.html',
'installCommand' => 'npm i vitepress && npm install',
'buildCommand' => 'npm run docs:build',
'outputDirectory' => './.vitepress/dist',
@@ -275,7 +272,7 @@ return [
'frameworks' => [
getFramework('VUE', [
'providerRootDirectory' => './vue/vuepress',
- 'outputDirectory' => '404.html',
+ 'fallbackFile' => '404.html',
'installCommand' => 'npm install',
'buildCommand' => 'npm run build',
'outputDirectory' => './src/.vuepress/dist',
@@ -298,7 +295,7 @@ return [
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './react/docusaurus',
- 'outputDirectory' => '404.html',
+ 'fallbackFile' => '404.html',
'installCommand' => 'npm install',
'buildCommand' => 'npm run build',
'outputDirectory' => './build',
diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php
index 3d7db8f457..67588ffd5d 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -207,7 +207,7 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr
}
-$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) {
+$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
// Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info)
$oauthProvider = null;
@@ -345,7 +345,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
->setProperty('secret', $sessionSecret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -353,8 +353,8 @@ $createSession = function (string $userId, string $secret, Request $request, Res
$protocol = $request->getProtocol();
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED);
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
@@ -403,7 +403,8 @@ Http::post('/v1/account')
->inject('dbForProject')
->inject('authorization')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Hooks $hooks, array $plan) {
$email = \strtolower($email);
if ('console' === $project->getId()) {
@@ -452,11 +453,38 @@ Http::post('/v1/account')
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$proof = new ProofsPassword();
$hash = $proof->hash($password);
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
try {
@@ -487,11 +515,11 @@ Http::post('/v1/account')
'authenticators' => null,
'search' => implode(' ', [$userId, $email, $name]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -691,15 +719,18 @@ Http::delete('/v1/account/sessions')
->inject('queueForDeletes')
->inject('store')
->inject('proofForToken')
- ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) {
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
+ $currentSession = null;
foreach ($sessions as $session) {/** @var Document $session */
$dbForProject->deleteDocument('sessions', $session->getId());
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
@@ -712,10 +743,11 @@ Http::delete('/v1/account/sessions')
// If current session delete the cookies too
$response
- ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
+ ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
// Use current session for events.
+ $currentSession = $session;
$queueForEvents
->setPayload($response->output($session, Response::MODEL_SESSION));
@@ -728,9 +760,11 @@ Http::delete('/v1/account/sessions')
$dbForProject->purgeCachedDocument('users', $user->getId());
- $queueForEvents
- ->setParam('userId', $user->getId())
- ->setParam('sessionId', $session->getId());
+ if ($currentSession instanceof Document) {
+ $queueForEvents
+ ->setParam('userId', $user->getId())
+ ->setParam('sessionId', $currentSession->getId());
+ }
$response->noContent();
});
@@ -776,7 +810,8 @@ Http::get('/v1/account/sessions/:sessionId')
->setAttribute('secret', $session->getAttribute('secret', ''))
;
- return $response->dynamic($session, Response::MODEL_SESSION);
+ $response->dynamic($session, Response::MODEL_SESSION);
+ return;
}
}
@@ -816,7 +851,9 @@ Http::delete('/v1/account/sessions/:sessionId')
->inject('queueForDeletes')
->inject('store')
->inject('proofForToken')
- ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) {
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current')
@@ -842,13 +879,13 @@ Http::delete('/v1/account/sessions/:sessionId')
->setAttribute('current', true)
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')));
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$response
- ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
+ ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
$dbForProject->purgeCachedDocument('users', $user->getId());
@@ -956,7 +993,7 @@ Http::patch('/v1/account/sessions/:sessionId')
->setPayload($response->output($session, Response::MODEL_SESSION))
;
- return $response->dynamic($session, Response::MODEL_SESSION);
+ $response->dynamic($session, Response::MODEL_SESSION);
});
Http::post('/v1/account/sessions/email')
@@ -1002,8 +1039,10 @@ Http::post('/v1/account/sessions/email')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@@ -1064,28 +1103,28 @@ Http::post('/v1/account/sessions/email')
]));
}
- $dbForProject->purgeCachedDocument('users', $user->getId());
-
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
+ $dbForProject->purgeCachedDocument('users', $user->getId());
+
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@@ -1151,8 +1190,10 @@ Http::post('/v1/account/sessions/anonymous')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
$protocol = $request->getProtocol();
if ('console' === $project->getId()) {
@@ -1243,15 +1284,15 @@ Http::post('/v1/account/sessions/anonymous')
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@@ -1306,7 +1347,9 @@ Http::post('/v1/account/sessions/token')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
-->inject('authorization')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->inject('authorization')
->action($createSession);
Http::get('/v1/account/sessions/oauth2/:provider')
@@ -1504,8 +1547,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
+ ->inject('plan')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) {
+ ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) use ($oauthDefaultSuccess) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$callbackBase = $protocol . '://' . $request->getHostname();
@@ -1747,10 +1793,38 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ $failureRedirect(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ $failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ $failureRedirect(Exception::USER_EMAIL_FREE);
}
try {
@@ -1780,11 +1854,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
'authenticators' => null,
'search' => implode(' ', [$userId, $email, $name]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -1859,19 +1933,47 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
if (empty($user->getAttribute('email'))) {
- $user->setAttribute('email', $oauth2->getUserEmail($accessToken));
+ $email = $oauth2->getUserEmail($accessToken);
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
try {
- $emailCanonical = new Email($user->getAttribute('email'));
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ $failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- $user->setAttribute('emailCanonical', $emailCanonical?->getCanonical());
- $user->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported());
- $user->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate());
- $user->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable());
- $user->setAttribute('emailIsFree', $emailCanonical?->isFree());
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ $failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ $failureRedirect(Exception::USER_EMAIL_FREE);
+ }
+
+ $user->setAttribute('email', $email);
+ $user->setAttribute('emailCanonical', $emailMetadata['emailCanonical']);
+ $user->setAttribute('emailIsCanonical', $emailMetadata['emailIsCanonical']);
+ $user->setAttribute('emailIsCorporate', $emailMetadata['emailIsCorporate']);
+ $user->setAttribute('emailIsDisposable', $emailMetadata['emailIsDisposable']);
+ $user->setAttribute('emailIsFree', $emailMetadata['emailIsFree']);
}
if (empty($user->getAttribute('name'))) {
@@ -1965,7 +2067,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -1978,17 +2080,17 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
// TODO: Remove this deprecated workaround - support only token
if ($state['success']['path'] == $oauthDefaultSuccess) {
$query['project'] = $project->getId();
- $query['domain'] = Config::getParam('cookieDomain');
+ $query['domain'] = $cookieDomain;
$query['key'] = $store->getKey();
$query['secret'] = $encoded;
}
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
- if (isset($sessionUpgrade) && $sessionUpgrade) {
+ if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) {
foreach ($user->getAttribute('targets', []) as $target) {
if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) {
continue;
@@ -2083,7 +2185,7 @@ Http::get('/v1/account/tokens/oauth2/:provider')
}
$host = $platform['consoleHostname'] ?? '';
- $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
+ $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$redirectBase = $protocol . '://' . $host;
if ($protocol === 'https' && $port !== '443') {
@@ -2106,10 +2208,12 @@ Http::get('/v1/account/tokens/oauth2/:provider')
'token' => true,
], $scopes);
+ $loginURL = $oauth2->getLoginURL();
+
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
- ->redirect($oauth2->getLoginURL());
+ ->redirect($loginURL);
});
Http::post('/v1/account/tokens/magic-url')
@@ -2149,10 +2253,11 @@ Http::post('/v1/account/tokens/magic-url')
->inject('locale')
->inject('queueForEvents')
->inject('queueForMails')
+ ->inject('plan')
->inject('proofForPassword')
->inject('platform')
->inject('authorization')
- ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
+ ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, array $plan, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2187,10 +2292,38 @@ Http::post('/v1/account/tokens/magic-url')
$userId = $userId === 'unique()' ? ID::unique() : $userId;
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user->setAttributes([
@@ -2217,11 +2350,11 @@ Http::post('/v1/account/tokens/magic-url')
'authenticators' => null,
'search' => implode(' ', [$userId, $email]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -2429,10 +2562,11 @@ Http::post('/v1/account/tokens/email')
->inject('locale')
->inject('queueForEvents')
->inject('queueForMails')
+ ->inject('plan')
->inject('proofForPassword')
->inject('proofForCode')
->inject('authorization')
- ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, array $plan, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2465,10 +2599,38 @@ Http::post('/v1/account/tokens/email')
$userId = $userId === 'unique()' ? ID::unique() : $userId;
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user->setAttributes([
@@ -2493,11 +2655,11 @@ Http::post('/v1/account/tokens/email')
'memberships' => null,
'search' => implode(' ', [$userId, $email]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -2738,11 +2900,13 @@ Http::put('/v1/account/sessions/magic-url')
->inject('queueForMails')
->inject('store')
->inject('proofForCode')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $authorization) use ($createSession) {
+ ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) {
$proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL);
$proofForToken->setHash(new Sha());
- $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $authorization);
+ $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization);
});
Http::put('/v1/account/sessions/phone')
@@ -2788,6 +2952,8 @@ Http::put('/v1/account/sessions/phone')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
->action($createSession);
@@ -3314,9 +3480,10 @@ Http::patch('/v1/account/email')
->inject('queueForEvents')
->inject('project')
->inject('hooks')
+ ->inject('plan')
->inject('proofForPassword')
- ->inject('authorization')
- ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, ProofsPassword $proofForPassword, Authorization $authorization) {
+ ->inject('authorization')
+ ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, array $plan, ProofsPassword $proofForPassword, Authorization $authorization) {
// passwordUpdate will be empty if the user has never set a password
$passwordUpdate = $user->getAttribute('passwordUpdate');
@@ -3344,20 +3511,48 @@ Http::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user
->setAttribute('email', $email)
->setAttribute('emailVerification', false) // After this user needs to confirm mail again
- ->setAttribute('emailCanonical', $emailCanonical?->getCanonical())
- ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported())
- ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate())
- ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable())
- ->setAttribute('emailIsFree', $emailCanonical?->isFree())
+ ->setAttribute('emailCanonical', $emailMetadata['emailCanonical'])
+ ->setAttribute('emailIsCanonical', $emailMetadata['emailIsCanonical'])
+ ->setAttribute('emailIsCorporate', $emailMetadata['emailIsCorporate'])
+ ->setAttribute('emailIsDisposable', $emailMetadata['emailIsDisposable'])
+ ->setAttribute('emailIsFree', $emailMetadata['emailIsFree'])
;
if (empty($passwordUpdate)) {
@@ -3550,7 +3745,9 @@ Http::patch('/v1/account/status')
->inject('dbForProject')
->inject('queueForEvents')
->inject('store')
- ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store) {
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store, bool $domainVerification, ?string $cookieDomain) {
$user->setAttribute('status', false);
@@ -3560,14 +3757,14 @@ Http::patch('/v1/account/status')
->setParam('userId', $user->getId())
->setPayload($response->output($user, Response::MODEL_ACCOUNT));
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$protocol = $request->getProtocol();
$response
- ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
;
$response->dynamic($user, Response::MODEL_ACCOUNT);
@@ -3760,7 +3957,7 @@ Http::post('/v1/account/recovery')
->setParam('userId', $profile->getId())
->setParam('tokenId', $recovery->getId())
->setUser($profile)
- ->setPayload(Response::showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -3861,7 +4058,7 @@ Http::put('/v1/account/recovery')
$queueForEvents
->setParam('userId', $profile->getId())
->setParam('tokenId', $recoveryDocument->getId())
- ->setPayload(Response::showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response->dynamic($recoveryDocument, Response::MODEL_TOKEN);
});
@@ -4091,7 +4288,7 @@ Http::post('/v1/account/verifications/email')
$queueForEvents
->setParam('userId', $user->getId())
->setParam('tokenId', $verification->getId())
- ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -4183,7 +4380,7 @@ Http::put('/v1/account/verifications/email')
$queueForEvents
->setParam('userId', $userId)
->setParam('tokenId', $verification->getId())
- ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response->dynamic($verification, Response::MODEL_TOKEN);
});
@@ -4715,5 +4912,5 @@ Http::delete('/v1/account/identities/:identityId')
->setParam('identityId', $identity->getId())
->setPayload($response->output($identity, Response::MODEL_IDENTITY));
- return $response->noContent();
+ $response->noContent();
});
diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php
index 2d0a840bd6..937380b643 100644
--- a/app/controllers/api/graphql.php
+++ b/app/controllers/api/graphql.php
@@ -28,12 +28,18 @@ use Utopia\Validator\Text;
Http::init()
->groups(['graphql'])
->inject('project')
+ ->inject('user')
+ ->inject('request')
+ ->inject('response')
->inject('authorization')
- ->action(function (Document $project, Authorization $authorization) {
+ ->action(function (Document $project, User $user, Request $request, Response $response, Authorization $authorization) {
+ $response->setUser($user);
+ $request->setUser($user);
+
if (
array_key_exists('graphql', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['graphql']
- && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
diff --git a/app/controllers/api/locale.php b/app/controllers/api/locale.php
index d6b4bb814d..c70102a6f1 100644
--- a/app/controllers/api/locale.php
+++ b/app/controllers/api/locale.php
@@ -231,6 +231,7 @@ Http::get('/v1/locale/continents')
->inject('locale')
->action(function (Response $response, Locale $locale) {
$list = array_keys(Config::getParam('locale-continents'));
+ $output = [];
foreach ($list as $value) {
$output[] = new Document([
diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php
index 1ba5eb1119..2a0012bd30 100644
--- a/app/controllers/api/messaging.php
+++ b/app/controllers/api/messaging.php
@@ -1180,6 +1180,7 @@ Http::get('/v1/messaging/providers/:providerId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -2585,6 +2586,7 @@ Http::get('/v1/messaging/topics/:topicId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3000,6 +3002,7 @@ Http::get('/v1/messaging/subscribers/:subscriberId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3566,7 +3569,7 @@ Http::post('/v1/messaging/messages/push')
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$endpoint = "$protocol://{$platform['apiHostname']}/v1";
- $scheduleTime = $currentScheduledAt ?? $scheduledAt;
+ $scheduleTime = $scheduledAt;
if (!\is_null($scheduleTime)) {
$expiry = (new \DateTime($scheduleTime))->add(new \DateInterval('P15D'))->format('U');
} else {
@@ -3813,6 +3816,7 @@ Http::get('/v1/messaging/messages/:messageId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php
index 5a87293b49..45a663fb56 100644
--- a/app/controllers/api/migrations.php
+++ b/app/controllers/api/migrations.php
@@ -29,6 +29,7 @@ use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite;
use Utopia\Migration\Sources\CSV;
use Utopia\Migration\Sources\Firebase;
+use Utopia\Migration\Sources\JSON;
use Utopia\Migration\Sources\NHost;
use Utopia\Migration\Sources\Supabase;
use Utopia\Migration\Transfer;
@@ -53,6 +54,15 @@ function getDatabaseTransferResourceServices(string $databaseType)
};
}
+function getDatabaseResourceType(string $databaseType): string
+{
+ return match($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+}
+
Http::post('/v1/migrations/appwrite')
->groups(['api', 'migrations'])
->desc('Create Appwrite migration')
@@ -447,6 +457,7 @@ Http::post('/v1/migrations/csv/imports')
}
$fileSize = $deviceForMigrations->getFileSize($newPath);
$resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
+ $resourceType = getDatabaseResourceType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => $migrationId,
@@ -456,7 +467,7 @@ Http::post('/v1/migrations/csv/imports')
'destination' => Appwrite::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
- 'resourceType' => Resource::TYPE_DATABASE,
+ 'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
@@ -565,16 +576,6 @@ Http::post('/v1/migrations/csv/exports')
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
- $validator = new Documents(
- attributes: $collection->getAttribute('attributes', []),
- indexes: $collection->getAttribute('indexes', []),
- idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
- );
-
- if (!$validator->isValid($parsedQueries)) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
- }
-
// getting databasetype
$resources = explode(':', $resourceId);
$databaseId = $resources[0];
@@ -583,7 +584,23 @@ Http::post('/v1/migrations/csv/exports')
if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
}
+
+ // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
+ $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
+
+ $validator = new Documents(
+ attributes: $collection->getAttribute('attributes', []),
+ indexes: $collection->getAttribute('indexes', []),
+ idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
+ supportForAttributes: !$isSchemaless,
+ );
+
+ if (!$validator->isValid($parsedQueries)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
$resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
+ $resourceType = getDatabaseResourceType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
@@ -593,7 +610,7 @@ Http::post('/v1/migrations/csv/exports')
'destination' => CSV::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
- 'resourceType' => Resource::TYPE_DATABASE,
+ 'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
@@ -624,6 +641,291 @@ Http::post('/v1/migrations/csv/exports')
->dynamic($migration, Response::MODEL_MIGRATION);
});
+Http::post('/v1/migrations/json/imports')
+ ->groups(['api', 'migrations'])
+ ->desc('Import documents from a JSON')
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createJSONImport',
+ description: '/docs/references/migrations/migration-json-import.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).')
+ ->param('fileId', '', new UID(), 'File ID.')
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
+ ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('deviceForFiles')
+ ->inject('deviceForMigrations')
+ ->inject('queueForEvents')
+ ->inject('queueForMigrations')
+ ->action(function (
+ string $bucketId,
+ string $fileId,
+ string $resourceId,
+ bool $internalFile,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Device $deviceForFiles,
+ Device $deviceForMigrations,
+ Event $queueForEvents,
+ Migration $queueForMigrations
+ ) {
+ $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
+ if ($internalFile) {
+ return $dbForPlatform->getDocument('buckets', 'default');
+ }
+ return $dbForProject->getDocument('buckets', $bucketId);
+ });
+
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
+ if ($file->isEmpty()) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
+ }
+
+ $path = $file->getAttribute('path', '');
+ if (!$deviceForFiles->exists($path)) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
+ }
+
+ // No encryption or compression on files above 20MB.
+ $hasEncryption = !empty($file->getAttribute('openSSLCipher'));
+ $compression = $file->getAttribute('algorithm', Compression::NONE);
+ $hasCompression = $compression !== Compression::NONE;
+
+ $migrationId = ID::unique();
+ $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json');
+
+ if ($hasEncryption || $hasCompression) {
+ $source = $deviceForFiles->read($path);
+
+ if ($hasEncryption) {
+ $source = OpenSSL::decrypt(
+ $source,
+ $file->getAttribute('openSSLCipher'),
+ System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
+ 0,
+ hex2bin($file->getAttribute('openSSLIV')),
+ hex2bin($file->getAttribute('openSSLTag'))
+ );
+ }
+
+ if ($hasCompression) {
+ switch ($compression) {
+ case Compression::ZSTD:
+ $source = (new Zstd())->decompress($source);
+ break;
+ case Compression::GZIP:
+ $source = (new GZIP())->decompress($source);
+ break;
+ }
+ }
+
+ // Manual write after decryption and/or decompression
+ if (!$deviceForMigrations->write($newPath, $source, 'application/json')) {
+ throw new \Exception('Unable to copy file');
+ }
+ } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
+ throw new \Exception('Unable to copy file');
+ }
+
+ $fileSize = $deviceForMigrations->getFileSize($newPath);
+
+ [$databaseId] = \explode(':', $resourceId, 2);
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ $databaseType = $database->getAttribute('type');
+ $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
+ $resourceType = getDatabaseResourceType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => $migrationId,
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => JSON::getName(),
+ 'destination' => Appwrite::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'path' => $newPath,
+ 'size' => $fileSize,
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $queueForMigrations
+ ->setMigration($migration)
+ ->setProject($project)
+ ->setPlatform($platform)
+ ->trigger();
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ });
+
+Http::post('/v1/migrations/json/exports')
+ ->groups(['api', 'migrations'])
+ ->desc('Export documents to JSON')
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createJSONExport',
+ description: '/docs/references/migrations/migration-json-export.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
+ ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.')
+ ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
+ ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
+ ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
+ ->inject('user')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('queueForMigrations')
+ ->action(function (
+ string $resourceId,
+ string $filename,
+ array $columns,
+ array $queries,
+ bool $notify,
+ Document $user,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ Migration $queueForMigrations
+ ) {
+ try {
+ $parsedQueries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ [$databaseId, $collectionId] = \explode(':', $resourceId, 2);
+ if (empty($databaseId)) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ if (empty($collectionId)) {
+ throw new Exception(Exception::COLLECTION_NOT_FOUND);
+ }
+
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+
+ $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
+ if ($collection->isEmpty()) {
+ throw new Exception(Exception::COLLECTION_NOT_FOUND);
+ }
+
+ $databaseType = $database->getAttribute('type');
+
+ // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
+ $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
+
+ $validator = new Documents(
+ attributes: $collection->getAttribute('attributes', []),
+ indexes: $collection->getAttribute('indexes', []),
+ idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
+ supportForAttributes: !$isSchemaless,
+ );
+
+ if (!$validator->isValid($parsedQueries)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
+ $resourceType = getDatabaseResourceType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => Appwrite::getName(),
+ 'destination' => JSON::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'bucketId' => 'default', // Always use internal bucket
+ 'filename' => $filename,
+ 'columns' => $columns,
+ 'queries' => $queries,
+ 'notify' => $notify,
+ 'userInternalId' => $user->getSequence(),
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $queueForMigrations
+ ->setMigration($migration)
+ ->setProject($project)
+ ->setPlatform($platform)
+ ->trigger();
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ });
+
Http::get('/v1/migrations')
->groups(['api', 'migrations'])
->desc('List migrations')
diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php
index 2fc20ba83f..dac6ed456a 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -5,28 +5,18 @@ use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
use Appwrite\Extend\Exception;
-use Appwrite\Network\Platform;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
-use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Database\Validator\Queries\Keys;
use Appwrite\Utopia\Response;
use PHPMailer\PHPMailer\PHPMailer;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
-use Utopia\Database\Exception\Duplicate;
-use Utopia\Database\Exception\Query as QueryException;
-use Utopia\Database\Helpers\ID;
-use Utopia\Database\Helpers\Permission;
-use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
-use Utopia\Database\Validator\Datetime as DatetimeValidator;
-use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Emails\Validator\Email;
use Utopia\Http\Http;
@@ -769,288 +759,6 @@ Http::delete('/v1/projects/:projectId')
$response->noContent();
});
-// Keys
-
-Http::post('/v1/projects/:projectId/keys')
- ->desc('Create key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'createKey',
- description: '/docs/references/projects/create-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_KEY,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- // TODO: When migrating to Platform API, mark keyId required for consistency
- ->param('keyId', 'unique()', fn (Database $dbForPlatform) => new CustomId($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true, ['dbForPlatform'])->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
- ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
- ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
- $keyId = $keyId == 'unique()' ? ID::unique() : $keyId;
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = new Document([
- '$id' => $keyId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => $project->getSequence(),
- 'resourceId' => $project->getId(),
- 'resourceType' => 'projects',
- 'name' => $name,
- 'scopes' => $scopes,
- 'expire' => $expire,
- 'sdks' => [],
- 'accessedAt' => null,
- 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
- ]);
-
- try {
- $key = $dbForPlatform->createDocument('keys', $key);
- } catch (Duplicate) {
- throw new Exception(Exception::KEY_ALREADY_EXISTS);
- }
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($key, Response::MODEL_KEY);
- });
-
-Http::get('/v1/projects/:projectId/keys')
- ->desc('List keys')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'listKeys',
- description: '/docs/references/projects/list-keys.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_KEY_LIST,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true)
- ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- try {
- $queries = Query::parseQueries($queries);
- } catch (QueryException $e) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
- }
-
- // Backwards compatibility
- if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) {
- $queries[] = Query::limit(5000);
- }
-
- $queries[] = Query::equal('resourceType', ['projects']);
- $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]);
-
- $cursor = Query::getCursorQueries($queries, false);
- $cursor = \reset($cursor);
-
- if ($cursor !== false) {
- $validator = new Cursor();
- if (!$validator->isValid($cursor)) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
- }
-
- $keyId = $cursor->getValue();
- $cursorDocument = $dbForPlatform->getDocument('keys', $keyId);
-
- if ($cursorDocument->isEmpty()) {
- throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found.");
- }
-
- $cursor->setValue($cursorDocument);
- }
-
- $filterQueries = Query::groupByType($queries)['filters'];
-
- $keys = $dbForPlatform->find('keys', $queries);
-
- $response->dynamic(new Document([
- 'keys' => $keys,
- 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0,
- ]), Response::MODEL_KEY_LIST);
- });
-
-Http::get('/v1/projects/:projectId/keys/:keyId')
- ->desc('Get key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'getKey',
- description: '/docs/references/projects/get-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_KEY,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = $dbForPlatform->findOne('keys', [
- Query::equal('$id', [$keyId]),
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$project->getSequence()]),
- ]);
-
- if ($key->isEmpty()) {
- throw new Exception(Exception::KEY_NOT_FOUND);
- }
-
- $response->dynamic($key, Response::MODEL_KEY);
- });
-
-Http::put('/v1/projects/:projectId/keys/:keyId')
- ->desc('Update key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'updateKey',
- description: '/docs/references/projects/update-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_KEY,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
- ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
- ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
- ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = $dbForPlatform->findOne('keys', [
- Query::equal('$id', [$keyId]),
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$project->getSequence()]),
- ]);
-
- if ($key->isEmpty()) {
- throw new Exception(Exception::KEY_NOT_FOUND);
- }
-
- $key
- ->setAttribute('name', $name)
- ->setAttribute('scopes', $scopes)
- ->setAttribute('expire', $expire);
-
- $dbForPlatform->updateDocument('keys', $key->getId(), $key);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->dynamic($key, Response::MODEL_KEY);
- });
-
-Http::delete('/v1/projects/:projectId/keys/:keyId')
- ->desc('Delete key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'deleteKey',
- description: '/docs/references/projects/delete-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- contentType: ContentType::NONE
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = $dbForPlatform->findOne('keys', [
- Query::equal('$id', [$keyId]),
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$project->getSequence()]),
- ]);
-
- if ($key->isEmpty()) {
- throw new Exception(Exception::KEY_NOT_FOUND);
- }
-
- $dbForPlatform->deleteDocument('keys', $key->getId());
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->noContent();
- });
-
// JWT Keys
Http::post('/v1/projects/:projectId/jwts')
@@ -1093,272 +801,6 @@ Http::post('/v1/projects/:projectId/jwts')
])]), Response::MODEL_JWT);
});
-// Platforms
-
-Http::post('/v1/projects/:projectId/platforms')
- ->desc('Create platform')
- ->groups(['api', 'projects'])
- ->label('audits.event', 'platforms.create')
- ->label('audits.resource', 'project/{request.projectId}')
- ->label('scope', 'platforms.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'createPlatform',
- description: '/docs/references/projects/create-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_PLATFORM,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param(
- 'type',
- null,
- new WhiteList([
- Platform::TYPE_WEB,
- Platform::TYPE_FLUTTER_WEB,
- Platform::TYPE_FLUTTER_IOS,
- Platform::TYPE_FLUTTER_ANDROID,
- Platform::TYPE_FLUTTER_LINUX,
- Platform::TYPE_FLUTTER_MACOS,
- Platform::TYPE_FLUTTER_WINDOWS,
- Platform::TYPE_APPLE_IOS,
- Platform::TYPE_APPLE_MACOS,
- Platform::TYPE_APPLE_WATCHOS,
- Platform::TYPE_APPLE_TVOS,
- Platform::TYPE_ANDROID,
- Platform::TYPE_UNITY,
- Platform::TYPE_REACT_NATIVE_IOS,
- Platform::TYPE_REACT_NATIVE_ANDROID,
- ], true),
- 'Platform type. Possible values are: web, flutter-web, flutter-ios, flutter-android, flutter-linux, flutter-macos, flutter-windows, apple-ios, apple-macos, apple-watchos, apple-tvos, android, unity, react-native-ios, react-native-android.'
- )
- ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
- ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', true)
- ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true)
- ->param('hostname', '', new Hostname(), 'Platform client hostname. Max length: 256 chars.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $name, string $key, string $store, string $hostname, Response $response, Database $dbForPlatform) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = new Document([
- '$id' => ID::unique(),
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'projectInternalId' => $project->getSequence(),
- 'projectId' => $project->getId(),
- 'type' => $type,
- 'name' => $name,
- 'key' => $key,
- 'store' => $store,
- 'hostname' => $hostname
- ]);
-
- $platform = $dbForPlatform->createDocument('platforms', $platform);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($platform, Response::MODEL_PLATFORM);
- });
-
-Http::get('/v1/projects/:projectId/platforms')
- ->desc('List platforms')
- ->groups(['api', 'projects'])
- ->label('scope', 'platforms.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'listPlatforms',
- description: '/docs/references/projects/list-platforms.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PLATFORM_LIST,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platforms = $dbForPlatform->find('platforms', [
- Query::equal('projectInternalId', [$project->getSequence()]),
- Query::limit(5000),
- ]);
-
- $response->dynamic(new Document([
- 'platforms' => $platforms,
- 'total' => $includeTotal ? count($platforms) : 0,
- ]), Response::MODEL_PLATFORM_LIST);
- });
-
-Http::get('/v1/projects/:projectId/platforms/:platformId')
- ->desc('Get platform')
- ->groups(['api', 'projects'])
- ->label('scope', 'platforms.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'getPlatform',
- description: '/docs/references/projects/get-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PLATFORM,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = $dbForPlatform->findOne('platforms', [
- Query::equal('$id', [$platformId]),
- Query::equal('projectInternalId', [$project->getSequence()]),
- ]);
-
- if ($platform->isEmpty()) {
- throw new Exception(Exception::PLATFORM_NOT_FOUND);
- }
-
- $response->dynamic($platform, Response::MODEL_PLATFORM);
- });
-
-Http::put('/v1/projects/:projectId/platforms/:platformId')
- ->desc('Update platform')
- ->groups(['api', 'projects'])
- ->label('scope', 'platforms.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'updatePlatform',
- description: '/docs/references/projects/update-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PLATFORM,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform'])
- ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
- ->param('key', '', new Text(256), 'Package name for android or bundle ID for iOS. Max length: 256 chars.', true)
- ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true)
- ->param('hostname', '', new Hostname(), 'Platform client URL. Max length: 256 chars.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $platformId, string $name, string $key, string $store, string $hostname, Response $response, Database $dbForPlatform) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = $dbForPlatform->findOne('platforms', [
- Query::equal('$id', [$platformId]),
- Query::equal('projectInternalId', [$project->getSequence()]),
- ]);
-
- if ($platform->isEmpty()) {
- throw new Exception(Exception::PLATFORM_NOT_FOUND);
- }
-
- $platform
- ->setAttribute('name', $name)
- ->setAttribute('key', $key)
- ->setAttribute('store', $store)
- ->setAttribute('hostname', $hostname);
-
- $dbForPlatform->updateDocument('platforms', $platform->getId(), $platform);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->dynamic($platform, Response::MODEL_PLATFORM);
- });
-
-Http::delete('/v1/projects/:projectId/platforms/:platformId')
- ->desc('Delete platform')
- ->groups(['api', 'projects'])
- ->label('audits.event', 'platforms.delete')
- ->label('audits.resource', 'project/{request.projectId}/platform/${request.platformId}')
- ->label('scope', 'platforms.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'deletePlatform',
- description: '/docs/references/projects/delete-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- contentType: ContentType::NONE
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = $dbForPlatform->findOne('platforms', [
- Query::equal('$id', [$platformId]),
- Query::equal('projectInternalId', [$project->getSequence()]),
- ]);
-
- if ($platform->isEmpty()) {
- throw new Exception(Exception::PLATFORM_NOT_FOUND);
- }
-
- $dbForPlatform->deleteDocument('platforms', $platformId);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->noContent();
- });
-
-
// CUSTOM SMTP and Templates
Http::patch('/v1/projects/:projectId/smtp')
->desc('Update SMTP')
@@ -1570,7 +1012,7 @@ Http::post('/v1/projects/:projectId/smtp/tests')
->trigger();
}
- return $response->noContent();
+ $response->noContent();
});
Http::get('/v1/projects/:projectId/templates/sms/:type/:locale')
diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php
index 3b21d4797d..a8875fc442 100644
--- a/app/controllers/api/users.php
+++ b/app/controllers/api/users.php
@@ -73,7 +73,7 @@ use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/** TODO: Remove function when we move to using utopia/platform */
-function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
+function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks, array $plan): Document
{
$name = $name ?? '';
$plaintextPassword = $password;
@@ -110,11 +110,39 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
}
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email ?? '');
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
}
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
+ }
+
$hashedPassword = null;
$isHashed = !$hash instanceof Plaintext;
@@ -159,11 +187,11 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $phone, $name]),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
if (!$isHashed && !empty($password)) {
@@ -256,10 +284,11 @@ Http::post('/v1/users')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$plaintext = new Plaintext();
- $user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
+ $user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($user, Response::MODEL_USER);
@@ -292,11 +321,12 @@ Http::post('/v1/users/bcrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$bcrypt = new Bcrypt();
$bcrypt->setCost(8); // Default cost
- $user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -330,10 +360,11 @@ Http::post('/v1/users/md5')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$md5 = new MD5();
- $user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -367,10 +398,11 @@ Http::post('/v1/users/argon2')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$argon2 = new Argon2();
- $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -405,13 +437,14 @@ Http::post('/v1/users/sha')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$sha = new Sha();
if (!empty($passwordVersion)) {
$sha->setVersion($passwordVersion);
}
- $user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -445,10 +478,11 @@ Http::post('/v1/users/phpass')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$phpass = new PHPass();
- $user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -487,7 +521,8 @@ Http::post('/v1/users/scrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$scrypt = new Scrypt();
$scrypt
->setSalt($passwordSalt)
@@ -496,7 +531,7 @@ Http::post('/v1/users/scrypt')
->setParallelCost($passwordParallel)
->setLength($passwordLength);
- $user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -533,14 +568,15 @@ Http::post('/v1/users/scrypt-modified')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$scryptModified = new ScryptModified();
$scryptModified
->setSalt($passwordSalt)
->setSaltSeparator($passwordSaltSeparator)
->setSignerKey($passwordSignerKey);
- $user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -972,6 +1008,8 @@ Http::get('/v1/users/:userId/logs')
'userId' => ID::custom($log['data']['userId']),
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
+ 'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -1470,8 +1508,10 @@ Http::patch('/v1/users/:userId/email')
->param('email', '', new EmailValidator(allowEmpty: true), 'User email.')
->inject('response')
->inject('dbForProject')
+ ->inject('project')
+ ->inject('plan')
->inject('queueForEvents')
- ->action(function (string $userId, string $email, Response $response, Database $dbForProject, Event $queueForEvents) {
+ ->action(function (string $userId, string $email, Response $response, Database $dbForProject, Document $project, array $plan, Event $queueForEvents) {
$user = $dbForProject->getDocument('users', $userId);
@@ -1502,20 +1542,47 @@ Http::patch('/v1/users/:userId/email')
$oldEmail = $user->getAttribute('email');
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ }
+
+ if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user
->setAttribute('email', $email)
->setAttribute('emailVerification', false)
- ->setAttribute('emailCanonical', $emailCanonical?->getCanonical())
- ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported())
- ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate())
- ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable())
- ->setAttribute('emailIsFree', $emailCanonical?->isFree())
+ ->setAttribute('emailCanonical', $emailMetadata['emailCanonical'])
+ ->setAttribute('emailIsCanonical', $emailMetadata['emailIsCanonical'])
+ ->setAttribute('emailIsCorporate', $emailMetadata['emailIsCorporate'])
+ ->setAttribute('emailIsDisposable', $emailMetadata['emailIsDisposable'])
+ ->setAttribute('emailIsFree', $emailMetadata['emailIsFree'])
;
try {
@@ -2337,9 +2404,8 @@ Http::post('/v1/users/:userId/sessions')
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION));
- return $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($session, Response::MODEL_SESSION);
+ $response->setStatusCode(Response::STATUS_CODE_CREATED);
+ $response->dynamic($session, Response::MODEL_SESSION);
});
Http::post('/v1/users/:userId/tokens')
@@ -2402,9 +2468,8 @@ Http::post('/v1/users/:userId/tokens')
->setParam('tokenId', $token->getId())
->setPayload($response->output($token, Response::MODEL_TOKEN));
- return $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($token, Response::MODEL_TOKEN);
+ $response->setStatusCode(Response::STATUS_CODE_CREATED);
+ $response->dynamic($token, Response::MODEL_TOKEN);
});
Http::delete('/v1/users/:userId/sessions/:sessionId')
@@ -2658,7 +2723,7 @@ Http::delete('/v1/users/identities/:identityId')
->setParam('identityId', $identity->getId())
->setPayload($response->output($identity, Response::MODEL_IDENTITY));
- return $response->noContent();
+ $response->noContent();
});
Http::post('/v1/users/:userId/jwts')
diff --git a/app/controllers/general.php b/app/controllers/general.php
index 51cce37fee..c6e2eacb33 100644
--- a/app/controllers/general.php
+++ b/app/controllers/general.php
@@ -61,8 +61,6 @@ use Utopia\System\System;
use Utopia\Validator;
use Utopia\Validator\Text;
-Config::setParam('domainVerification', false);
-Config::setParam('cookieDomain', 'localhost');
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
@@ -166,14 +164,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if ($request->getMethod() !== Request::METHOD_GET) {
throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.', view: $errorView);
}
- return $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ return false;
}
}
/** @var Database $dbForProject */
$dbForProject = $getProjectDB($project);
- /** @var Document $deployment */
if (!empty($rule->getAttribute('deploymentId', ''))) {
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId')));
} else {
@@ -244,6 +242,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if ($isPreview && $requirePreview) {
$cookie = $request->getCookie(COOKIE_NAME_PREVIEW, '');
$authorized = false;
+ $user = new Document();
// Security checks to mark authorized true
if (!empty($cookie)) {
@@ -273,7 +272,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$membershipExists = false;
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
- if (!$project->isEmpty() && isset($user)) {
+ if (!$project->isEmpty() && !$user->isEmpty()) {
$teamId = $project->getAttribute('teamId', '');
$membership = $user->find('teamId', $teamId, 'memberships');
if (!empty($membership)) {
@@ -379,7 +378,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$executionId = ID::unique();
$headers = \array_merge([], $requestHeaders);
- $headers['x-appwrite-execution-id'] = $executionId ?? '';
+ $headers['x-appwrite-execution-id'] = $executionId;
$headers['x-appwrite-user-id'] = '';
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
@@ -459,7 +458,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if ($version === 'v2') {
$vars = \array_merge($vars, [
'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
- 'APPWRITE_FUNCTION_DATA' => $body ?? '',
+ 'APPWRITE_FUNCTION_DATA' => $body,
'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
]);
@@ -529,6 +528,11 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
/** Execute function */
+ $executionResponse = [
+ 'headers' => [],
+ 'body' => '',
+ ];
+
try {
$version = match ($type) {
'function' => $resource->getAttribute('version', 'v2'),
@@ -734,7 +738,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$execution->setAttribute('responseBody', $executionResponse['body'] ?? '');
$execution->setAttribute('responseHeaders', $headers);
- $body = $execution['responseBody'] ?? '';
+ $body = $execution['responseBody'];
$contentType = 'text/plain';
foreach ($executionResponse['headers'] as $name => $values) {
@@ -748,11 +752,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
if (\is_array($values)) {
- $count = 0;
foreach ($values as $value) {
- $override = $count === 0;
- $response->addHeader($name, $value, override: $override);
- $count++;
+ $response->addHeader($name, $value);
}
} else {
$response->addHeader($name, $values);
@@ -862,12 +863,12 @@ Http::init()
* Request format
*/
$route = $utopia->getRoute();
- Request::setRoute($route);
+ $request->setRoute($route);
if ($route === null) {
- return $response
- ->setStatusCode(404)
- ->send('Not Found');
+ $response->setStatusCode(404);
+ $response->send('Not Found');
+ return;
}
$requestFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
@@ -898,40 +899,16 @@ Http::init()
$locale->setDefault($localeParam);
}
- $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
- $selfDomain = new Domain($request->getHostname());
- $endDomain = new Domain((string)$origin);
- Config::setParam(
- 'domainVerification',
- ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) &&
- $endDomain->getRegisterable() !== ''
- );
-
$localHosts = ['localhost','localhost:'.$request->getPort()];
$migrationHost = System::getEnv('_APP_MIGRATION_HOST');
if (!empty($migrationHost)) {
+ // Treat the migration host like localhost because internal migration and
+ // CI traffic may use it before a public domain is configured.
$localHosts[] = $migrationHost;
$localHosts[] = $migrationHost.':'.$request->getPort();
}
- $isLocalHost = in_array($request->getHostname(), $localHosts);
- $isIpAddress = filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false;
-
- $isConsoleProject = $project->getAttribute('$id', '') === 'console';
- $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled';
-
- Config::setParam(
- 'cookieDomain',
- $isLocalHost || $isIpAddress
- ? null
- : (
- $isConsoleProject && $isConsoleRootSession
- ? '.' . $selfDomain->getRegisterable()
- : '.' . $request->getHostname()
- )
- );
-
$warnings = [];
/*
@@ -973,7 +950,8 @@ Http::init()
throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.');
}
- return $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ return;
}
}
});
@@ -1012,7 +990,7 @@ Http::init()
return;
}
$route = $request->getRoute();
- if ($route->getLabel('origin', false) === '*') {
+ if ($route?->getLabel('origin', false) === '*') {
return;
}
if (!$originValidator->isValid($origin)) {
@@ -1270,7 +1248,16 @@ Http::error()
* If not a publishable error, track usage stats. Publishable errors are >= 500 or those explicitly marked as publish=true in errors.php
*/
if (!$publish && $project->getId() !== 'console') {
- if (!DBUser::isPrivileged($authorization->getRoles())) {
+ $errorUser = new DBUser();
+ try {
+ $resolvedUser = $utopia->getResource('user');
+ if ($resolvedUser instanceof DBUser) {
+ $errorUser = $resolvedUser;
+ }
+ } catch (\Throwable) {
+ // User resource may not be available in error context
+ }
+ if (!$errorUser->isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
@@ -1477,6 +1464,21 @@ Http::error()
'type' => $type,
];
+ // Add CORS headers to error responses so browsers can read the error.
+ // Wrapped in try-catch: if the error itself is a DB failure, resolving
+ // the cors resource (which depends on rule -> DB) would cascade.
+ // Uses override:true to avoid duplicate headers if init() already set them.
+ try {
+ $cors = $utopia->getResource('cors');
+ foreach ($cors->headers($request->getOrigin()) as $name => $value) {
+ $response
+ ->removeHeader($name)
+ ->addHeader($name, $value);
+ }
+ } catch (Throwable) {
+ // Degrade gracefully - error response without CORS is no worse than before.
+ }
+
$response
->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
->addHeader('Expires', '0')
diff --git a/app/controllers/mock.php b/app/controllers/mock.php
index 712d4b7742..99713af430 100644
--- a/app/controllers/mock.php
+++ b/app/controllers/mock.php
@@ -244,36 +244,38 @@ Http::get('/v1/mock/github/callback')
throw new Exception(Exception::PROJECT_NOT_FOUND, $error);
}
- if (!empty($providerInstallationId)) {
- $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
- $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
- $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
-
- $projectInternalId = $project->getSequence();
-
- $teamId = $project->getAttribute('teamId', '');
-
- $installation = new Document([
- '$id' => ID::unique(),
- '$permissions' => [
- Permission::read(Role::team(ID::custom($teamId))),
- Permission::update(Role::team(ID::custom($teamId), 'owner')),
- Permission::update(Role::team(ID::custom($teamId), 'developer')),
- Permission::delete(Role::team(ID::custom($teamId), 'owner')),
- Permission::delete(Role::team(ID::custom($teamId), 'developer')),
- ],
- 'providerInstallationId' => $providerInstallationId,
- 'projectId' => $projectId,
- 'projectInternalId' => $projectInternalId,
- 'provider' => 'github',
- 'organization' => $owner,
- 'personal' => false
- ]);
-
- $installation = $dbForPlatform->createDocument('installations', $installation);
+ if (empty($providerInstallationId)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Missing provider installation ID');
}
+ $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
+ $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
+ $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
+ $owner = $github->getOwnerName($providerInstallationId) ?? '';
+
+ $projectInternalId = $project->getSequence();
+
+ $teamId = $project->getAttribute('teamId', '');
+
+ $installation = new Document([
+ '$id' => ID::unique(),
+ '$permissions' => [
+ Permission::read(Role::team(ID::custom($teamId))),
+ Permission::update(Role::team(ID::custom($teamId), 'owner')),
+ Permission::update(Role::team(ID::custom($teamId), 'developer')),
+ Permission::delete(Role::team(ID::custom($teamId), 'owner')),
+ Permission::delete(Role::team(ID::custom($teamId), 'developer')),
+ ],
+ 'providerInstallationId' => $providerInstallationId,
+ 'projectId' => $projectId,
+ 'projectInternalId' => $projectInternalId,
+ 'provider' => 'github',
+ 'organization' => $owner,
+ 'personal' => false
+ ]);
+
+ $installation = $dbForPlatform->createDocument('installations', $installation);
+
$response->json([
'installationId' => $installation->getId(),
]);
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index f98b9ed454..11ac345fca 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -96,8 +96,11 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
+ ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
+ }
/**
* Handle user authentication and session validation.
@@ -183,7 +186,7 @@ Http::init()
$user = new User([
'$id' => '',
'status' => true,
- 'type' => ACTIVITY_TYPE_APP,
+ 'type' => ACTIVITY_TYPE_KEY_PROJECT,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $apiKey->getName(),
@@ -253,7 +256,14 @@ Http::init()
}
}
- $queueForAudits->setUser($user);
+ $userClone = clone $user;
+ $userClone->setAttribute('type', match ($apiKey->getType()) {
+ API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT,
+ API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT,
+ API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION,
+ default => ACTIVITY_TYPE_KEY_PROJECT,
+ });
+ $queueForAudits->setUser($userClone);
}
// Apply permission
@@ -419,7 +429,7 @@ Http::init()
if (
array_key_exists($namespace, $project->getAttribute('services', []))
&& ! $project->getAttribute('services', [])[$namespace]
- && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new Exception(Exception::GENERAL_SERVICE_DISABLED);
}
@@ -483,9 +493,16 @@ 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, 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) {
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $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) {
+
+ $response->setUser($user);
+ $request->setUser($user);
$route = $utopia->getRoute();
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
+ }
+
$path = $route->getMatchedPath();
$databaseType = match (true) {
str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
@@ -496,7 +513,7 @@ Http::init()
if (
array_key_exists('rest', $project->getAttribute('apis', []))
&& ! $project->getAttribute('apis', [])['rest']
- && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -528,8 +545,8 @@ Http::init()
$closestLimit = null;
$roles = $authorization->getRoles();
- $isPrivilegedUser = User::isPrivileged($roles);
- $isAppUser = User::isApp($roles);
+ $isPrivilegedUser = $user->isPrivileged($roles);
+ $isAppUser = $user->isApp($roles);
foreach ($timeLimitArray as $timeLimit) {
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
@@ -589,7 +606,9 @@ Http::init()
if (! $user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
- $userClone->setAttribute('type', ACTIVITY_TYPE_USER);
+ if (empty($user->getAttribute('type'))) {
+ $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
+ }
$queueForAudits->setUser($userClone);
}
@@ -611,7 +630,7 @@ Http::init()
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));
@@ -630,7 +649,7 @@ Http::init()
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -663,7 +682,7 @@ Http::init()
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
// Do not update transformedAt if it's a console user
- if (! User::isPrivileged($authorization->getRoles())) {
+ 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());
@@ -697,7 +716,7 @@ Http::init()
->groups(['session'])
->inject('user')
->inject('request')
- ->action(function (Document $user, Request $request) {
+ ->action(function (User $user, Request $request) {
if (\str_contains($request->getURI(), 'oauth2')) {
return;
}
@@ -771,7 +790,8 @@ Http::shutdown()
->inject('eventProcessor')
->inject('bus')
->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) {
+ ->inject('mode')
+ ->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, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -873,7 +893,9 @@ Http::shutdown()
if (! $user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
- $userClone->setAttribute('type', ACTIVITY_TYPE_USER);
+ if (empty($user->getAttribute('type'))) {
+ $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
+ }
$queueForAudits->setUser($userClone);
} elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) {
/**
@@ -984,7 +1006,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,
diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php
index 6e1f9f389f..db98d97bf5 100644
--- a/app/controllers/shared/api/auth.php
+++ b/app/controllers/shared/api/auth.php
@@ -36,8 +36,9 @@ Http::init()
->inject('request')
->inject('project')
->inject('geodb')
+ ->inject('user')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, Authorization $authorization) {
+ ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist && $project->getId() === 'console')) {
$countries = explode(',', $denylist);
@@ -50,8 +51,8 @@ Http::init()
$route = $utopia->match($request);
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
- $isAppUser = User::isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ $isAppUser = $user->isApp($authorization->getRoles());
if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs
return;
diff --git a/app/http.php b/app/http.php
index 517a1aa544..67da67376d 100644
--- a/app/http.php
+++ b/app/http.php
@@ -1,14 +1,13 @@
column('value', Table::TYPE_INT, 1);
$certifiedDomains->create();
-Http::setResource('riskyDomains', fn () => $riskyDomains);
-Http::setResource('certifiedDomains', fn () => $certifiedDomains);
-
-$http = new Server(
- host: "0.0.0.0",
- port: System::getEnv('PORT', 80),
- mode: SWOOLE_PROCESS,
-);
+global $container;
+$container->set('riskyDomains', fn () => $riskyDomains);
+$container->set('certifiedDomains', fn () => $certifiedDomains);
+$container->set('pools', function ($register) {
+ return $register->get('pools');
+}, ['register']);
$payloadSize = 12 * (1024 * 1024); // 12MB - adding slight buffer for headers and other data that might be sent with the payload - update later with valid testing
$totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
+$swooleAdapter = new Server(
+ host: "0.0.0.0",
+ port: System::getEnv('PORT', 80),
+ settings: [
+ Constant::OPTION_WORKER_NUM => $totalWorkers,
+ Constant::OPTION_DISPATCH_FUNC => dispatch(...),
+ Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD,
+ Constant::OPTION_HTTP_COMPRESSION => false,
+ Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
+ Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
+ Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
+ ],
+ container: $container,
+);
+
+$container->set('container', fn () => fn () => $swooleAdapter->getContainer());
+
+$http = $swooleAdapter->getServer();
+
/**
* Assigns HTTP requests to worker threads by analyzing its payload/content.
*
@@ -68,16 +84,16 @@ $totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intva
* riskier tasks to a dedicated worker subset. Prefers idle workers, with fallback to random selection if necessary.
* doc: https://openswoole.com/docs/modules/swoole-server/configuration#dispatch_func
*
- * @param Server $server Swoole server instance.
+ * @param \Swoole\Http\Server $server Swoole server instance.
* @param int $fd client ID
* @param int $type the type of data and its current state
* @param string|null $data Request content for categorization.
* @global int $totalThreads Total number of workers.
* @return int Chosen worker ID for the request.
*/
-function dispatch(Server $server, int $fd, int $type, $data = null): int
+function dispatch(\Swoole\Http\Server $server, int $fd, int $type, $data = null): int
{
- $resolveWorkerId = function (Server $server, $data = null) {
+ $resolveWorkerId = function (\Swoole\Http\Server $server, $data = null) {
global $totalWorkers, $riskyDomains;
// If data is not set we can send request to any worker
@@ -103,7 +119,7 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int
$lines = explode("\n", $data, 3);
$request = $lines[0];
if (count($lines) > 1) {
- $domain = trim(explode('Host: ', $lines[1])[1]);
+ $domain = trim(explode('Host: ', $lines[1])[1] ?? '');
}
// Sync executions are considered risky
@@ -160,18 +176,6 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int
return $workerId;
}
-
-$http
- ->set([
- Constant::OPTION_WORKER_NUM => $totalWorkers,
- Constant::OPTION_DISPATCH_FUNC => dispatch(...),
- Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD,
- Constant::OPTION_HTTP_COMPRESSION => false,
- Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
- Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
- Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
- ]);
-
$http->on(Constant::EVENT_WORKER_START, function ($server, $workerId) {
});
@@ -188,16 +192,14 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
Console::success('Reload completed...');
});
-Http::setResource('bus', function ($register, $utopia) {
- return $register->get('bus')->setResolver(fn (string $name) => $utopia->getResource($name));
-}, ['register', 'utopia']);
+$container->set('bus', function ($register) use ($swooleAdapter) {
+ return $register->get('bus')->setResolver(fn (string $name) => $swooleAdapter->getContainer()->get($name));
+}, ['register']);
include __DIR__ . '/controllers/general.php';
function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
{
- $max = 15;
- $sleep = 2;
$max = 15;
$sleep = 2;
$attempts = 0;
@@ -288,13 +290,13 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
Span::current()?->finish();
}
-$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $totalWorkers, $register) {
- $app = new Http('UTC');
+$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $swooleAdapter) {
+ $app = new Http($swooleAdapter, 'UTC');
- go(function () use ($register, $app) {
- $pools = $register->get('pools');
- /** @var Group $pools */
- Http::setResource('pools', fn () => $pools);
+ /** @var \Utopia\Pools\Group $pools */
+ $pools = $app->getResource('pools');
+
+ go(function () use ($app, $pools) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
@@ -510,14 +512,11 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot
});
});
-$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) use ($register, $files) {
+$swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swooleAdapter, $registerRequestResources) {
Span::init('http.request');
- Http::setResource('swooleRequest', fn () => $swooleRequest);
- Http::setResource('swooleResponse', fn () => $swooleResponse);
-
- $request = new Request($swooleRequest);
- $response = new Response($swooleResponse);
+ $request = new Request($utopiaRequest->getSwooleRequest());
+ $response = new Response($utopiaResponse->getSwooleResponse());
Span::add('http.method', $request->getMethod());
@@ -533,13 +532,18 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
return;
}
- $app = new Http('UTC');
+ $requestContainer = $swooleAdapter->getContainer();
+ $requestContainer->set('request', fn () => $request);
+ $requestContainer->set('response', fn () => $response);
+
+ $app = new Http($swooleAdapter, 'UTC');
+ $requestContainer->set('utopia', fn () => $app);
+
+ $registerRequestResources($requestContainer);
+
$app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled');
$app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB
- $pools = $register->get('pools');
- Http::setResource('pools', fn () => $pools);
-
try {
$authorization = $app->getResource('authorization');
@@ -623,6 +627,7 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
}
}
+ $swooleResponse = $utopiaResponse->getSwooleResponse();
$swooleResponse->setStatusCode(500);
$output = ((Http::isDevelopment())) ? [
@@ -646,16 +651,15 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
});
// Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory
-$http->on(Constant::EVENT_TASK, function () use ($register) {
+$http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
$lastSyncUpdate = null;
- $pools = $register->get('pools');
- Http::setResource('pools', fn () => $pools);
- $app = new Http('UTC');
+
+ $app = new Http($swooleAdapter, 'UTC');
/** @var Utopia\Database\Database $dbForPlatform */
$dbForPlatform = $app->getResource('dbForPlatform');
- /** @var Table $riskyDomains */
+ /** @var \Swoole\Table $riskyDomains */
$riskyDomains = $app->getResource('riskyDomains');
Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $app) {
@@ -725,4 +729,4 @@ $http->on(Constant::EVENT_TASK, function () use ($register) {
});
});
-$http->start();
+$swooleAdapter->start();
diff --git a/app/init/constants.php b/app/init/constants.php
index 8fdd6d1a51..3b907572ab 100644
--- a/app/init/constants.php
+++ b/app/init/constants.php
@@ -97,6 +97,7 @@ const APP_COMPUTE_DEPLOYMENT_MAX_RETENTION = 100 * 365; // 100 years
const APP_SDK_PLATFORM_SERVER = 'server';
const APP_SDK_PLATFORM_CLIENT = 'client';
const APP_SDK_PLATFORM_CONSOLE = 'console';
+const APP_SDK_PLATFORM_STATIC = 'static';
const APP_VCS_GITHUB_USERNAME = 'Appwrite';
const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io';
const APP_VCS_GITHUB_URL = 'https://github.com/TeamAppwrite';
@@ -155,9 +156,12 @@ const SESSION_PROVIDER_SERVER = 'server';
/**
* Activity associated with user or the app.
*/
-const ACTIVITY_TYPE_APP = 'app';
const ACTIVITY_TYPE_USER = 'user';
+const ACTIVITY_TYPE_ADMIN = 'admin';
const ACTIVITY_TYPE_GUEST = 'guest';
+const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject';
+const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount';
+const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization';
/**
* MFA
diff --git a/app/init/database/filters.php b/app/init/database/filters.php
index c2ba091529..5a65479424 100644
--- a/app/init/database/filters.php
+++ b/app/init/database/filters.php
@@ -1,5 +1,6 @@
getAuthorization()->skip(fn () => $database
+ $platforms = $database->getAuthorization()->skip(fn () => $database
->find('platforms', [
Query::equal('projectInternalId', [$document->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]));
+
+ foreach ($platforms as $platform) {
+ $platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type')));
+ }
+
+ return $platforms;
}
);
diff --git a/app/init/models.php b/app/init/models.php
index bf6d67dd95..dd97b03652 100644
--- a/app/init/models.php
+++ b/app/init/models.php
@@ -106,7 +106,12 @@ use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
use Appwrite\Utopia\Response\Model\Phone;
-use Appwrite\Utopia\Response\Model\Platform;
+use Appwrite\Utopia\Response\Model\PlatformAndroid;
+use Appwrite\Utopia\Response\Model\PlatformApple;
+use Appwrite\Utopia\Response\Model\PlatformLinux;
+use Appwrite\Utopia\Response\Model\PlatformList;
+use Appwrite\Utopia\Response\Model\PlatformWeb;
+use Appwrite\Utopia\Response\Model\PlatformWindows;
use Appwrite\Utopia\Response\Model\Preferences;
use Appwrite\Utopia\Response\Model\Project;
use Appwrite\Utopia\Response\Model\Provider;
@@ -197,7 +202,6 @@ Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, '
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true));
Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false));
Response::setModel(new BaseList('Auth Providers List', Response::MODEL_AUTH_PROVIDER_LIST, 'platforms', Response::MODEL_AUTH_PROVIDER, true, false));
-Response::setModel(new BaseList('Platforms List', Response::MODEL_PLATFORM_LIST, 'platforms', Response::MODEL_PLATFORM, true, false));
Response::setModel(new BaseList('Countries List', Response::MODEL_COUNTRY_LIST, 'countries', Response::MODEL_COUNTRY));
Response::setModel(new BaseList('Continents List', Response::MODEL_CONTINENT_LIST, 'continents', Response::MODEL_CONTINENT));
Response::setModel(new BaseList('Languages List', Response::MODEL_LANGUAGE_LIST, 'languages', Response::MODEL_LANGUAGE));
@@ -333,7 +337,12 @@ Response::setModel(new Key());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
Response::setModel(new AuthProvider());
-Response::setModel(new Platform());
+Response::setModel(new PlatformWeb());
+Response::setModel(new PlatformApple());
+Response::setModel(new PlatformAndroid());
+Response::setModel(new PlatformWindows());
+Response::setModel(new PlatformLinux());
+Response::setModel(new PlatformList());
Response::setModel(new Variable());
Response::setModel(new Country());
Response::setModel(new Continent());
diff --git a/app/init/registers.php b/app/init/registers.php
index 7c2f822fdd..c07bc9da8b 100644
--- a/app/init/registers.php
+++ b/app/init/registers.php
@@ -6,7 +6,6 @@ use Appwrite\Hooks\Hooks;
use Appwrite\PubSub\Adapter\Redis as PubSub;
use Appwrite\URL\URL as AppwriteURL;
use MaxMind\Db\Reader;
-use PHPMailer\PHPMailer\PHPMailer;
use Swoole\Database\PDOProxy;
use Utopia\Cache\Adapter\Redis as RedisCache;
use Utopia\Config\Config;
@@ -25,6 +24,7 @@ use Utopia\Logger\Adapter\LogOwl;
use Utopia\Logger\Adapter\Raygun;
use Utopia\Logger\Adapter\Sentry;
use Utopia\Logger\Logger;
+use Utopia\Messaging\Adapter\Email\SMTP;
use Utopia\Mongo\Client as MongoClient;
use Utopia\Pools\Adapter\Stack as StackPool;
use Utopia\Pools\Adapter\Swoole as SwoolePool;
@@ -56,7 +56,7 @@ $register->set('logger', function () {
}
try {
- $loggingProvider = new DSN($providerConfig ?? '');
+ $loggingProvider = new DSN($providerConfig);
$providerName = $loggingProvider->getScheme();
$providerConfig = match ($providerName) {
@@ -76,7 +76,7 @@ $register->set('logger', function () {
};
}
- if (empty($providerName) || empty($providerConfig)) {
+ if (empty($providerName)) {
return;
}
@@ -121,7 +121,7 @@ $register->set('realtimeLogger', function () {
default => ['key' => $loggingProvider->getHost()],
};
- if (empty($providerName) || empty($providerConfig)) {
+ if (empty($providerName)) {
return;
}
@@ -242,22 +242,11 @@ $register->set('pools', function () {
],
];
- $maxConnections = System::getEnv('_APP_CONNECTIONS_MAX', 151);
- $instanceConnections = $maxConnections / System::getEnv('_APP_POOL_CLIENTS', 14);
+ $maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151);
+ $instanceConnections = $maxConnections / (int) System::getEnv('_APP_POOL_CLIENTS', 14);
- $multiprocessing = System::getEnv('_APP_SERVER_MULTIPROCESS', 'disabled') === 'enabled';
-
- if ($multiprocessing) {
- $workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
- } else {
- $workerCount = 1;
- }
-
- if ($workerCount > $instanceConnections) {
- throw new \Exception('Pool size is too small. Increase the number of allowed database connections or decrease the number of workers.', 500);
- }
-
- $poolSize = (int)($instanceConnections / $workerCount);
+ $workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
+ $poolSize = max(1, (int)($instanceConnections / $workerCount));
foreach ($connections as $key => $connection) {
$type = $connection['type'] ?? '';
@@ -308,7 +297,7 @@ $register->set('pools', function () {
]);
});
},
- 'mongodb' => function () use ($dsnHost, $dsnPort, $dsnUser, $dsnPass, $dsnDatabase, $dsn) {
+ 'mongodb' => function () use ($dsnHost, $dsnPort, $dsnUser, $dsnPass, $dsnDatabase) {
try {
$mongo = new MongoClient($dsnDatabase, $dsnHost, (int)$dsnPort, $dsnUser, $dsnPass, false);
@$mongo->connect();
@@ -433,35 +422,20 @@ $register->set('db', function () {
});
$register->set('smtp', function () {
- $mail = new PHPMailer(true);
-
- $mail->isSMTP();
-
- $username = System::getEnv('_APP_SMTP_USERNAME');
- $password = System::getEnv('_APP_SMTP_PASSWORD');
-
- $mail->XMailer = 'Appwrite Mailer';
- $mail->Host = System::getEnv('_APP_SMTP_HOST', 'smtp');
- $mail->Port = System::getEnv('_APP_SMTP_PORT', 25);
- $mail->SMTPAuth = !empty($username) && !empty($password);
- $mail->Username = $username;
- $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.) */
-
- $from = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
- $email = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
-
- $mail->setFrom($email, $from);
- $mail->addReplyTo($email, $from);
-
- $mail->isHTML(true);
-
- return $mail;
+ $username = System::getEnv('_APP_SMTP_USERNAME', '');
+ $password = System::getEnv('_APP_SMTP_PASSWORD', '');
+ return new SMTP(
+ host: System::getEnv('_APP_SMTP_HOST', 'smtp'),
+ port: (int) System::getEnv('_APP_SMTP_PORT', 25),
+ username: $username,
+ password: $password,
+ smtpSecure: System::getEnv('_APP_SMTP_SECURE', ''),
+ smtpAutoTLS: false,
+ xMailer: 'Appwrite Mailer',
+ timeout: 10,
+ keepAlive: true,
+ timelimit: 30,
+ );
});
$register->set('geodb', function () {
return new Reader(__DIR__ . '/../assets/dbip/dbip-country-lite-2025-12.mmdb');
diff --git a/app/init/resources.php b/app/init/resources.php
index 9883d6d644..fdca88c30e 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -1,47 +1,10 @@
new Log());
-Http::setResource('logger', function ($register) {
+global $register;
+global $container;
+$container = new Container();
+
+$container->set('logger', function ($register) {
return $register->get('logger');
}, ['register']);
-Http::setResource('hooks', function ($register) {
+$container->set('hooks', function ($register) {
return $register->get('hooks');
}, ['register']);
-global $register;
-Http::setResource('register', fn () => $register);
-Http::setResource('locale', function () {
- $locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
- $locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+$container->set('register', fn () => $register);
- return $locale;
-});
-
-Http::setResource('localeCodes', function () {
+$container->set('localeCodes', function () {
return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []));
});
-// Queues
-Http::setResource('publisher', function (Group $pools) {
+// Queues - shared infrastructure (stateless pool wrappers)
+$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
-Http::setResource('publisherDatabases', function (Publisher $publisher) {
+$container->set('publisherDatabases', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherFunctions', function (Publisher $publisher) {
+$container->set('publisherFunctions', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMigrations', function (Publisher $publisher) {
+$container->set('publisherMigrations', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMails', function (Publisher $publisher) {
+$container->set('publisherMails', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherDeletes', function (Publisher $publisher) {
+$container->set('publisherDeletes', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMessaging', function (Publisher $publisher) {
+$container->set('publisherMessaging', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherWebhooks', function (Publisher $publisher) {
+$container->set('publisherWebhooks', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
-}, ['publisher']);
-Http::setResource('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
-}, ['publisher']);
-Http::setResource('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
-}, ['publisher']);
-Http::setResource('queueForScreenshots', function (Publisher $publisher) {
- return new Screenshot($publisher);
-}, ['publisher']);
-Http::setResource('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
-}, ['publisher']);
-Http::setResource('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
-}, ['publisher']);
-Http::setResource('queueForEvents', function (Publisher $publisher) {
- return new Event($publisher);
-}, ['publisher']);
-Http::setResource('queueForWebhooks', function (Publisher $publisher) {
- return new Webhook($publisher);
-}, ['publisher']);
-Http::setResource('queueForRealtime', function () {
- return new Realtime();
-}, []);
-Http::setResource('usage', function () {
- return new UsageContext();
-}, []);
-Http::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+$container->set('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']);
-Http::setResource('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
-}, ['publisher']);
-Http::setResource('eventProcessor', function () {
- return new EventProcessor();
-}, []);
-Http::setResource('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
-}, ['publisher']);
-Http::setResource('queueForMigrations', function (Publisher $publisher) {
- return new Migration($publisher);
-}, ['publisher']);
-Http::setResource('queueForStatsResources', function (Publisher $publisher) {
- return new StatsResources($publisher);
-}, ['publisher']);
/**
* Platform configuration
*/
-Http::setResource('platform', function () {
+$container->set('platform', function () {
return Config::getParam('platform', []);
}, []);
-/**
- * List of allowed request hostnames for the request.
- */
-Http::setResource('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
- $allowed = [...($platform['hostnames'] ?? [])];
-
- /* Add platform configured hostnames */
- 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()) {
- $allowed[] = $request->getHostname();
- }
-
- $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST);
- $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST);
-
- $hostname = $originHostname;
- if (empty($hostname)) {
- $hostname = $refererHostname;
- }
-
- /* Add request hostname for preflight requests */
- if ($request->getMethod() === 'OPTIONS') {
- $allowed[] = $hostname;
- }
-
- /* Allow the request origin of rule */
- 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)) {
- $allowed[] = $hostname;
- }
-
- return array_unique($allowed);
-}, ['platform', 'project', 'rule', 'devKey', 'request']);
-
-/**
- * List of allowed request schemes for the request.
- */
-Http::setResource('allowedSchemes', function (array $platform, Document $project) {
- $allowed = [...($platform['schemas'] ?? [])];
-
- if (! $project->isEmpty() && $project->getId() !== 'console') {
- /* Add hardcoded schemes */
- $allowed[] = 'exp';
- $allowed[] = 'appwrite-callback-' . $project->getId();
-
- /* Add platform configured schemes */
- $platforms = $project->getAttribute('platforms', []);
- $schemes = Platform::getSchemes($platforms);
- $allowed = [...$allowed, ...$schemes];
- }
-
- return array_unique($allowed);
-}, ['platform', 'project']);
-
-/**
- * Rule associated with a request origin.
- */
-Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
- $domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
-
- if (empty($domain)) {
- $domain = \parse_url($request->getReferer(), PHP_URL_HOST);
- }
-
- if (empty($domain)) {
- return new Document();
- }
-
- // TODO: (@Meldiron) Remove after 1.7.x migration
- $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
- $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
- if ($isMd5) {
- return $dbForPlatform->getDocument('rules', md5($domain));
- }
-
- return $dbForPlatform->findOne('rules', [
- Query::equal('domain', [$domain]),
- ]) ?? new Document();
- });
-
- $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
-
- // 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', ''))) {
- $trustedProjects = [];
- foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
- if (empty($trustedProject)) {
- continue;
- }
- $trustedProjects[] = $trustedProject;
- }
- if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) {
- $permitsCurrentProject = true;
- }
- }
-
- if (! $permitsCurrentProject) {
- return new Document();
- }
-
- return $rule;
-}, ['request', 'dbForPlatform', 'project', 'authorization']);
-
-/**
- * CORS service
- */
-Http::setResource('cors', function (array $allowedHostnames) {
- $corsConfig = Config::getParam('cors');
-
- return new Cors(
- $allowedHostnames,
- allowedMethods: $corsConfig['allowedMethods'],
- allowedHeaders: $corsConfig['allowedHeaders'],
- allowCredentials: true,
- exposedHeaders: $corsConfig['exposedHeaders'],
- );
-}, ['allowedHostnames']);
-
-Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
- 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()) {
- return new URL();
- }
-
- return new Redirect($allowedHostnames, $allowedSchemes);
-}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
-
-Http::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
- /**
- * Handles user authentication and session validation.
- *
- * This function follows a series of steps to determine the appropriate user session
- * based on cookies, headers, and JWT tokens.
- *
- * Process:
- * 1. Checks the cookie based on mode:
- * - If in admin mode, uses console project id for key.
- * - Otherwise, sets the key using the project ID
- * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`.
- * - If this method is used, returns the header: `X-Debug-Fallback: true`.
- * 3. Fetches the user document from the appropriate database based on the mode.
- * 4. If the user document is empty or the session key cannot be verified, sets an empty user document.
- * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token.
- * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`,
- * 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 ($mode === APP_MODE_ADMIN) {
- $store->setKey('a_session_' . $console->getId());
- }
-
- $store->decode(
- $request->getCookie(
- $store->getKey(), // Get sessions
- $request->getCookie($store->getKey() . '_legacy', '')
- )
- );
-
- // Get session from header for SSR clients
- if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
- $sessionHeader = $request->getHeader('x-appwrite-session', '');
-
- if (! empty($sessionHeader)) {
- $store->decode($sessionHeader);
- }
- }
-
- // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
- if ($response) { // if in http context - add debug header
- $response->addHeader('X-Debug-Fallback', 'false');
- }
-
- if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
- if ($response) {
- $response->addHeader('X-Debug-Fallback', 'true');
- }
- $fallback = $request->getHeader('x-fallback-cookies', '');
- $fallback = \json_decode($fallback, true);
- $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
- }
-
- $user = null;
- 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 ($project->getId() === 'console') {
- /** @var User $user */
- $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
- } else {
- /** @var User $user */
- $user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
- }
- }
- }
- }
-
- if (
- ! $user ||
- $user->isEmpty() // Check a document has been found in the DB
- || ! $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()) {
- throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
- }
-
- $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
- try {
- $payload = $jwt->decode($authJWT);
- } catch (JWTException $error) {
- throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
- }
-
- $jwtUserId = $payload['userId'] ?? '';
- if (! empty($jwtUserId)) {
- if ($mode === APP_MODE_ADMIN) {
- $user = $dbForPlatform->getDocument('users', $jwtUserId);
- } else {
- $user = $dbForProject->getDocument('users', $jwtUserId);
- }
- }
- $jwtSessionId = $payload['sessionId'] ?? '';
- if (! empty($jwtSessionId)) {
- if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
- $user = new User([]);
- }
- }
- }
-
- // 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()) {
- throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
- }
-
- $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
- if (! $accountKeyUser->isEmpty()) {
- $key = $accountKeyUser->find(
- key: 'secret',
- find: $accountKey,
- subject: 'keys'
- );
-
- if (! empty($key)) {
- $expire = $key->getAttribute('expire');
- if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
- throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
- }
-
- $user = $accountKeyUser;
- }
- }
- }
-
- // Impersonation: if current user has impersonator capability and headers are set, act as another user
- $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
- $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
- $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
- if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
- $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
- $targetUser = null;
- if (!empty($impersonateUserId)) {
- $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId));
- } elseif (!empty($impersonateEmail)) {
- $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])]));
- } elseif (!empty($impersonatePhone)) {
- $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])]));
- }
- if ($targetUser !== null && !$targetUser->isEmpty()) {
- $impersonator = clone $user;
- $user = clone $targetUser;
- $user->setAttribute('impersonatorUserId', $impersonator->getId());
- $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence());
- $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', ''));
- $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', ''));
- $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0));
- }
- }
-
- $dbForProject->setMetadata('user', $user->getId());
- $dbForPlatform->setMetadata('user', $user->getId());
-
- return $user;
-}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
-
-Http::setResource('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
- /** @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)) {
- $projectId = $request->getHeader('x-appwrite-project', '');
- }
-
- // Backwards compatibility for new services, originally project resources
- // These endpoints moved from /v1/projects/:projectId/ to /v1/
- // When accessed via the old alias path, extract projectId from the URI
- $deprecatedProjectPathPrefix = '/v1/projects/';
- $route = $utopia->match($request);
- if (!empty($route)) {
- $isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) &&
- !\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix);
-
- if ($isDeprecatedAlias) {
- $projectId = \explode('/', $request->getURI(), 5)[3] ?? '';
- }
- }
-
- if (empty($projectId) || $projectId === 'console') {
- return $console;
- }
-
- $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
-
- return $project;
-}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
-
-Http::setResource('session', function (User $user, Store $store, Token $proofForToken) {
- if ($user->isEmpty()) {
- return;
- }
-
- $sessions = $user->getAttribute('sessions', []);
- $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
-
- if (! $sessionId) {
- return;
- }
- foreach ($sessions as $session) {
- /** @var Document $session */
- if ($sessionId === $session->getId()) {
- return $session;
- }
- }
-
-}, ['user', 'store', 'proofForToken']);
-
-Http::setResource('store', function (): Store {
- return new Store();
-});
-
-Http::setResource('proofForPassword', function (): Password {
- $hash = new Argon2();
- $hash
- ->setMemoryCost(7168)
- ->setTimeCost(5)
- ->setThreads(1);
-
- $password = new Password();
- $password
- ->setHash($hash);
-
- return $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;
-});
-
-Http::setResource('console', function () {
+$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
-Http::setResource('authorization', function () {
+$container->set('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, UsageContext $usage, Authorization $authorization, Request $request) {
- if ($project->isEmpty() || $project->getId() === 'console') {
- return $dbForPlatform;
- }
-
- $database = $project->getAttribute('database', '');
- if (empty($database)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
- }
-
- try {
- $dsn = new DSN($database);
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $database);
- }
-
- $adapter = new DatabasePool($pools->get($dsn->getHost()));
- $database = new Database($adapter, $cache);
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setMetadata('host', \gethostname())
- ->setMetadata('project', $project->getId())
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
- ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
- $database->setDocumentType('users', User::class);
-
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- if (\in_array($dsn->getHost(), $sharedTables)) {
- $database
- ->setSharedTables(true)
- ->setTenant($project->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
-
- /**
- * This isolated event handling for `users.*.create` which is based on a `Database::EVENT_DOCUMENT_CREATE` listener may look odd, but it is **intentional**.
- *
- * Accounts can be created in many ways beyond `createAccount`
- * (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here.
- */
- $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
- // Only trigger events for user creation with the database listener.
- if ($document->getCollection() !== 'users') {
- return;
- }
-
- $queueForEvents
- ->setEvent('users.[userId].create')
- ->setParam('userId', $document->getId())
- ->setPayload($response->output($document, Response::MODEL_USER));
-
- // Trigger functions, webhooks, and realtime events
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
-
- /** Trigger webhooks events only if a project has them enabled */
- if (! empty($project->getAttribute('webhooks'))) {
- $queueForWebhooks
- ->from($queueForEvents)
- ->trigger();
- }
-
- /** Trigger realtime events only for non console events */
- if ($queueForEvents->getProject()->getId() !== 'console') {
- $queueForRealtime
- ->from($queueForEvents)
- ->trigger();
- }
- };
-
- /**
- * Purge function events cache when functions are created, updated or deleted.
- */
- $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) {
-
- if ($document->getCollection() !== 'functions') {
- return;
- }
-
- if ($project->isEmpty() || $project->getId() === 'console') {
- return;
- }
-
- $hostname = $dbForProject->getAdapter()->getHostname();
- $cacheKey = \sprintf(
- '%s-cache-%s:%s:%s:project:%s:functions:events',
- $dbForProject->getCacheName(),
- $hostname ?? '',
- $dbForProject->getNamespace(),
- $dbForProject->getTenant(),
- $project->getId()
- );
-
- $dbForProject->getCache()->purge($cacheKey);
- };
-
- /**
- * Prefix metrics with database type when applicable.
- * Avoids prefixing for legacy and tablesdb types to preserve historical metrics.
- */
- $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string {
- if (
- $databaseType === '' ||
- $databaseType === DATABASE_TYPE_LEGACY ||
- $databaseType === DATABASE_TYPE_TABLESDB
- ) {
- return $metric;
- }
-
- return $databaseType . '.' . $metric;
- };
-
- // Determine database type from request path, similar to api.php
- $path = $request->getURI();
- $databaseType = match (true) {
- str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
- str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
- default => '',
- };
-
- $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) use ($getDatabaseTypePrefixedMetric, $databaseType) {
- $value = 1;
-
- switch ($event) {
- case Database::EVENT_DOCUMENT_DELETE:
- $value = -1;
- break;
- case Database::EVENT_DOCUMENTS_DELETE:
- $value = -1 * $document->getAttribute('modified', 0);
- break;
- case Database::EVENT_DOCUMENTS_CREATE:
- $value = $document->getAttribute('modified', 0);
- break;
- case Database::EVENT_DOCUMENTS_UPSERT:
- $value = $document->getAttribute('created', 0);
- break;
- }
-
- switch (true) {
- case $document->getCollection() === 'teams':
- $usage->addMetric(METRIC_TEAMS, $value); // per project
- break;
- case $document->getCollection() === 'users':
- $usage->addMetric(METRIC_USERS, $value); // per project
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $usage->addReduce($document);
- }
- break;
- case $document->getCollection() === 'sessions': // sessions
- $usage->addMetric(METRIC_SESSIONS, $value); // per project
- break;
- case $document->getCollection() === 'databases': // databases
- $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES);
- $usage->addMetric($metric, $value); // per project
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $usage->addReduce($document);
- }
- break;
- case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS);
- $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS);
- $usage
- ->addMetric($collectionMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value);
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $usage->addReduce($document);
- }
- break;
- case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS);
- $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS);
- $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS);
- $usage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- break;
- case $document->getCollection() === 'buckets': // buckets
- $usage->addMetric(METRIC_BUCKETS, $value); // per project
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $usage
- ->addReduce($document);
- }
- break;
- case str_starts_with($document->getCollection(), 'bucket_'): // files
- $parts = explode('_', $document->getCollection());
- $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':
- $usage->addMetric(METRIC_FUNCTIONS, $value); // per project
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $usage
- ->addReduce($document);
- }
- break;
- case $document->getCollection() === 'sites':
- $usage->addMetric(METRIC_SITES, $value); // per project
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $usage
- ->addReduce($document);
- }
- break;
- case $document->getCollection() === 'deployments':
- $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
- ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value)
- ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS), $value) // per function
- ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value);
- break;
- default:
- break;
- }
- };
-
- // Clone the queues, to prevent events triggered by the database listener
- // from overwriting the events that are supposed to be triggered in the shutdown hook.
- $queueForEventsClone = new Event($publisher);
- $queueForFunctions = new Func($publisherFunctions);
- $queueForWebhooks = new Webhook($publisherWebhooks);
- $queueForRealtime = new Realtime();
-
- $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', 'usage', 'authorization', 'request']);
-
-Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
+$container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = new DatabasePool($pools->get('console'));
$database = new Database($adapter, $cache);
@@ -884,200 +117,7 @@ Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authori
return $database;
}, ['pools', 'cache', 'authorization']);
-Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
-
- return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database {
- $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
- $databaseType = $database->getAttribute('type', '');
-
- try {
- $databaseDSN = new DSN($databaseDSN);
- } catch (\InvalidArgumentException) {
- // for old databases migrated through patch script
- // databaseDSN determines the adapter
- $databaseDSN = new DSN('mysql://'.$databaseDSN);
- }
- try {
- $dsn = new DSN($project->getAttribute('database'));
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $project->getAttribute('database'));
- }
-
- $pool = $pools->get($databaseDSN->getHost());
-
- $adapter = new DatabasePool($pool);
- $database = new Database($adapter, $cache);
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setMetadata('host', \gethostname())
- ->setMetadata('project', $project->getId())
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
- ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
- // inside pools authorization needs to be set first
- $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
- if (\in_array($dsn->getHost(), $sharedTables)) {
- $database
- ->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
- $timeout = \intval($request->getHeader('x-appwrite-timeout'));
- if (!empty($timeout) && Http::isDevelopment()) {
- $database->setTimeout($timeout);
- }
-
- // Register database event listeners for usage stats collection
- $documentsMetric = METRIC_DOCUMENTS;
- $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS;
- $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS;
- if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) {
- $documentsMetric = $databaseType. '.' .$documentsMetric;
- $databaseIdDocumentsMetric = $databaseType. '.' .$databaseIdDocumentsMetric;
- $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' .$databaseIdCollectionIdDocumentsMetric;
- }
- $database
- ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = 1;
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $usage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = -1;
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $usage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = $document->getAttribute('modified', 0);
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $usage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = -1 * $document->getAttribute('modified', 0);
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $usage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = $document->getAttribute('created', 0);
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $usage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- });
-
- return $database;
- };
-
-}, ['pools','cache','project','request','usage','authorization']);
-
-Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) {
- $databases = [];
-
- return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) {
- if ($project->isEmpty() || $project->getId() === 'console') {
- return $dbForPlatform;
- }
-
- $database = $project->getAttribute('database', '');
- if (empty($database)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
- }
-
- try {
- $dsn = new DSN($database);
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $database);
- }
-
- $configure = (function (Database $database) use ($project, $dsn, $authorization) {
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setMetadata('host', \gethostname())
- ->setMetadata('project', $project->getId())
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
- ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES)
- ->setDocumentType('users', User::class);
-
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- if (\in_array($dsn->getHost(), $sharedTables)) {
- $database
- ->setSharedTables(true)
- ->setTenant($project->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
- });
-
- if (isset($databases[$dsn->getHost()])) {
- $database = $databases[$dsn->getHost()];
- $configure($database);
-
- return $database;
- }
-
- $adapter = new DatabasePool($pools->get($dsn->getHost()));
- $database = new Database($adapter, $cache);
- $databases[$dsn->getHost()] = $database;
- $configure($database);
-
- return $database;
- };
-}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
-
-Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
@@ -1106,15 +146,9 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
};
}, ['pools', 'cache', 'authorization']);
-Http::setResource('audit', function ($dbForProject) {
- $adapter = new AdapterDatabase($dbForProject);
+$container->set('telemetry', fn () => new NoTelemetry());
- return new Audit($adapter);
-}, ['dbForProject']);
-
-Http::setResource('telemetry', fn () => new NoTelemetry());
-
-Http::setResource('cache', function (Group $pools, Telemetry $telemetry) {
+$container->set('cache', function (Group $pools, Telemetry $telemetry) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -1128,7 +162,7 @@ Http::setResource('cache', function (Group $pools, Telemetry $telemetry) {
return $cache;
}, ['pools', 'telemetry']);
-Http::setResource('redis', function () {
+$container->set('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
$pass = System::getEnv('_APP_REDIS_PASS', '');
@@ -1143,31 +177,15 @@ Http::setResource('redis', function () {
return $redis;
});
-Http::setResource('timelimit', function (\Redis $redis) {
+$container->set('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
};
}, ['redis']);
-Http::setResource('deviceForLocal', function (Telemetry $telemetry) {
+$container->set('deviceForLocal', function (Telemetry $telemetry) {
return new Device\Telemetry($telemetry, new Local());
}, ['telemetry']);
-Http::setResource('deviceForFiles', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForSites', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForMigrations', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForFunctions', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForBuilds', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
function getDevice(string $root, string $connection = ''): Device
{
$connection = ! empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', '');
@@ -1275,33 +293,17 @@ function getDevice(string $root, string $connection = ''): Device
}
}
-Http::setResource('mode', function (Request $request, Document $project) {
- /**
- * Defines the mode for the request:
- * - 'default' => Requests for Client and Server Side
- * - 'admin' => Request from the Console on non-console projects
- */
- $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT));
-
- $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', ''));
- if (!empty($projectId) && $project->getId() !== $projectId) {
- $mode = APP_MODE_ADMIN;
- }
-
- return $mode;
-}, ['request', 'project']);
-
-Http::setResource('geodb', function ($register) {
+$container->set('geodb', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('geodb');
}, ['register']);
-Http::setResource('passwordsDictionary', function ($register) {
+$container->set('passwordsDictionary', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary');
}, ['register']);
-Http::setResource('servers', function () {
+$container->set('servers', function () {
$platforms = Config::getParam('sdks');
$server = $platforms[APP_SDK_PLATFORM_SERVER];
@@ -1312,358 +314,25 @@ Http::setResource('servers', function () {
return $languages;
});
-Http::setResource('promiseAdapter', function ($register) {
+$container->set('promiseAdapter', function ($register) {
return $register->get('promiseAdapter');
}, ['register']);
-Http::setResource('schema', function ($utopia, $dbForProject, $authorization) {
-
- $complexity = function (int $complexity, array $args) {
- $queries = Query::parseQueries($args['queries'] ?? []);
- $query = Query::getByType($queries, [Query::TYPE_LIMIT])[0] ?? null;
- $limit = $query ? $query->getValue() : APP_LIMIT_LIST_DEFAULT;
-
- return $complexity * $limit;
- };
-
- $attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) {
- $attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [
- Query::limit($limit),
- Query::offset($offset),
- ]));
-
- return \array_map(function ($attr) {
- return $attr->getArrayCopy();
- }, $attrs);
- };
-
- $urls = [
- 'list' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents";
- },
- 'create' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents";
- },
- 'read' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
- },
- 'update' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
- },
- 'delete' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
- },
- ];
-
- // NOTE: `params` and `urls` are not used internally in the `Schema::build` function below!
- $params = [
- 'list' => function (string $databaseId, string $collectionId, array $args) {
- return ['queries' => $args['queries']];
- },
- 'create' => function (string $databaseId, string $collectionId, array $args) {
- $id = $args['id'] ?? 'unique()';
- $permissions = $args['permissions'] ?? null;
-
- unset($args['id']);
- unset($args['permissions']);
-
- // Order must be the same as the route params
- return [
- 'databaseId' => $databaseId,
- 'documentId' => $id,
- 'collectionId' => $collectionId,
- 'data' => $args,
- 'permissions' => $permissions,
- ];
- },
- 'update' => function (string $databaseId, string $collectionId, array $args) {
- $documentId = $args['id'];
- $permissions = $args['permissions'] ?? null;
-
- unset($args['id']);
- unset($args['permissions']);
-
- // Order must be the same as the route params
- return [
- 'databaseId' => $databaseId,
- 'collectionId' => $collectionId,
- 'documentId' => $documentId,
- 'data' => $args,
- 'permissions' => $permissions,
- ];
- },
- ];
-
- return Schema::build(
- $utopia,
- $complexity,
- $attributes,
- $urls,
- $params,
- );
-}, ['utopia', 'dbForProject', 'authorization']);
-
-Http::setResource('gitHub', function (Cache $cache) {
+$container->set('gitHub', function (Cache $cache) {
return new VcsGitHub($cache);
}, ['cache']);
-Http::setResource('requestTimestamp', function ($request) {
- // TODO: Move this to the Request class itself
- $timestampHeader = $request->getHeader('x-appwrite-timestamp');
- $requestTimestamp = null;
- 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']);
-
-Http::setResource('plan', function (array $plan = []) {
+$container->set('plan', function () {
return [];
});
-Http::setResource('smsRates', function () {
+$container->set('smsRates', function () {
return [];
});
-Http::setResource('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
- $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
-
- // Check if given key match project's development keys
- $key = $project->find('secret', $devKey, 'devKeys');
- if (! $key) {
- return new Document([]);
- }
-
- // check expiration
- $expire = $key->getAttribute('expire');
- if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
- return new Document([]);
- }
-
- // update access time
- $accessedAt = $key->getAttribute('accessedAt', 0);
- if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
- $key->setAttribute('accessedAt', DatabaseDateTime::now());
- $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
- 'accessedAt' => $key->getAttribute('accessedAt')
- ])));
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
- }
-
- // add sdk to key
- $sdkValidator = new WhiteList($servers, true);
- $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
-
- if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
- $sdks = $key->getAttribute('sdks', []);
-
- if (! in_array($sdk, $sdks)) {
- $sdks[] = $sdk;
- $key->setAttribute('sdks', $sdks);
-
- /** Update access time as well */
- $key->setAttribute('accessedAt', DatabaseDateTime::now());
- $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
- 'sdks' => $key->getAttribute('sdks'),
- 'accessedAt' => $key->getAttribute('accessedAt')
- ])));
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
- }
- }
-
- return $key;
-}, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
-
-Http::setResource('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
- $teamInternalId = '';
- if ($project->getId() !== 'console') {
- $teamInternalId = $project->getAttribute('teamInternalId', '');
- } else {
- $route = $utopia->match($request);
- $path = ! empty($route) ? $route->getPath() : $request->getURI();
- $orgHeader = $request->getHeader('x-appwrite-organization', '');
- if (str_starts_with($path, '/v1/projects/:projectId')) {
- $uri = $request->getURI();
- $pid = explode('/', $uri)[3];
- $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid));
- $teamInternalId = $p->getAttribute('teamInternalId', '');
- } elseif ($path === '/v1/projects') {
- $teamId = $request->getParam('teamId', '');
-
- if (empty($teamId)) {
- return new Document([]);
- }
-
- $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));
-
- return $team;
- } elseif (! empty($orgHeader)) {
- return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
- }
- }
-
- // if teamInternalId is empty, return an empty document
-
- if (empty($teamInternalId)) {
- return new Document([]);
- }
-
- $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) {
- return $dbForPlatform->findOne('teams', [
- Query::equal('$sequence', [$teamInternalId]),
- ]);
- });
-
- return $team;
-}, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
-
-Http::setResource(
+$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
-Http::setResource('previewHostname', function (Request $request, ?Key $apiKey) {
- $allowed = false;
-
- if (Http::isDevelopment()) {
- $allowed = 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)) {
- return $host;
- }
- }
-
- return '';
-}, ['request', 'apiKey']);
-
-Http::setResource('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
- $key = $request->getHeader('x-appwrite-key');
-
- if (empty($key)) {
- return null;
- }
-
- $key = Key::decode($project, $team, $user, $key);
-
- $userHeader = $request->getHeader('x-appwrite-user');
- $organizationHeader = $request->getHeader('x-appwrite-organization');
- $projectHeader = $request->getHeader('x-appwrite-project');
-
- if (! empty($key->getProjectId())) {
- if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) {
- throw new Exception(Exception::PROJECT_ID_MISSING);
- }
- }
-
- if (! empty($key->getUserId())) {
- if (empty($userHeader) || $userHeader !== $key->getUserId()) {
- throw new Exception(Exception::USER_ID_MISSING);
- }
- }
-
- if (! empty($key->getTeamId())) {
- if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) {
- throw new Exception(Exception::ORGANIZATION_ID_MISSING);
- }
- }
-
- return $key;
-}, ['request', 'project', 'team', 'user']);
-
-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
- // 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.
-
- try {
- $payload = $jwt->decode($tokenJWT);
- } catch (JWTException $error) {
- return new Document([]);
- }
-
- $tokenId = $payload['tokenId'] ?? '';
- if (empty($tokenId)) {
- return new Document([]);
- }
-
- $token = $authorization->skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId));
-
- if ($token->isEmpty()) {
- return new Document([]);
- }
-
- $expiry = $token->getAttribute('expire');
-
- if ($expiry !== null) {
- $now = new \DateTime();
- $expiryDate = new \DateTime($expiry);
-
- if ($expiryDate < $now) {
- return new Document([]);
- }
- }
-
- return match ($token->getAttribute('resourceType')) {
- TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject, $authorization) {
- $sequences = explode(':', $token->getAttribute('resourceInternalId'));
- $ids = explode(':', $token->getAttribute('resourceId'));
-
- if (count($sequences) !== 2 || count($ids) !== 2) {
- return new Document([]);
- }
-
- $accessedAt = $token->getAttribute('accessedAt', 0);
- if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) {
- $token->setAttribute('accessedAt', DatabaseDateTime::now());
- $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([
- 'accessedAt' => $token->getAttribute('accessedAt')
- ])));
- }
-
- return new Document([
- 'bucketId' => $ids[0],
- 'fileId' => $ids[1],
- 'bucketInternalId' => $sequences[0],
- 'fileInternalId' => $sequences[1],
- ]);
- })(),
-
- default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID),
- };
- }
-
- return new Document([]);
-}, ['project', 'dbForProject', 'request', 'authorization']);
-
-Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) {
- return new TransactionState($dbForProject, $authorization, $getDatabasesDB);
-}, ['dbForProject', 'authorization', 'getDatabasesDB']);
-
-Http::setResource('executionsRetentionCount', function (Document $project, array $plan) {
- if ($project->getId() === 'console' || empty($plan)) {
- return 0;
- }
-
- return (int) ($plan['executionsRetentionCount'] ?? 100);
-}, ['project', 'plan']);
-
-Http::setResource('embeddingAgent', function ($register) {
- $adapter = new Ollama();
- $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
- $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
- return new Agent($adapter);
-}, ['register']);
+$container->set('executor', fn () => new Executor());
diff --git a/app/init/resources/request.php b/app/init/resources/request.php
new file mode 100644
index 0000000000..156e151501
--- /dev/null
+++ b/app/init/resources/request.php
@@ -0,0 +1,1477 @@
+set('utopia:graphql', function ($utopia) {
+ return $utopia;
+ }, ['utopia']);
+
+ $container->set('log', fn () => new Log(), []);
+
+ $container->set('logger', function ($register) {
+ return $register->get('logger');
+ }, ['register']);
+
+ $container->set('authorization', function () {
+ return new Authorization();
+ }, []);
+
+ $container->set('store', function (): Store {
+ return new Store();
+ }, []);
+
+ $container->set('proofForPassword', function (): Password {
+ $hash = new Argon2();
+ $hash
+ ->setMemoryCost(7168)
+ ->setTimeCost(5)
+ ->setThreads(1);
+
+ $password = new Password();
+ $password
+ ->setHash($hash);
+
+ return $password;
+ });
+
+ $container->set('proofForToken', function (): Token {
+ $token = new Token();
+ $token->setHash(new Sha());
+
+ return $token;
+ });
+
+ $container->set('proofForCode', function (): Code {
+ $code = new Code();
+ $code->setHash(new Sha());
+
+ return $code;
+ });
+
+ $container->set('locale', function () {
+ $locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
+ $locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+
+ return $locale;
+ });
+
+ // Per-request queue resources (stateful, accumulate event data during request)
+ $container->set('queueForMessaging', function (Publisher $publisher) {
+ return new Messaging($publisher);
+ }, ['publisher']);
+ $container->set('queueForMails', function (Publisher $publisher) {
+ return new Mail($publisher);
+ }, ['publisher']);
+ $container->set('queueForBuilds', function (Publisher $publisher) {
+ return new Build($publisher);
+ }, ['publisher']);
+ $container->set('queueForScreenshots', function (Publisher $publisher) {
+ return new Screenshot($publisher);
+ }, ['publisher']);
+ $container->set('queueForDatabase', function (Publisher $publisher) {
+ return new EventDatabase($publisher);
+ }, ['publisher']);
+ $container->set('queueForDeletes', function (Publisher $publisher) {
+ return new Delete($publisher);
+ }, ['publisher']);
+ $container->set('queueForEvents', function (Publisher $publisher) {
+ return new Event($publisher);
+ }, ['publisher']);
+ $container->set('queueForWebhooks', function (Publisher $publisher) {
+ return new Webhook($publisher);
+ }, ['publisher']);
+ $container->set('queueForRealtime', function () {
+ return new Realtime();
+ }, []);
+ $container->set('usage', function () {
+ return new UsageContext();
+ }, []);
+ $container->set('queueForAudits', function (Publisher $publisher) {
+ return new AuditEvent($publisher);
+ }, ['publisher']);
+ $container->set('queueForFunctions', function (Publisher $publisher) {
+ return new Func($publisher);
+ }, ['publisher']);
+ $container->set('eventProcessor', function () {
+ return new EventProcessor();
+ }, []);
+ $container->set('queueForCertificates', function (Publisher $publisher) {
+ return new Certificate($publisher);
+ }, ['publisher']);
+ $container->set('queueForMigrations', function (Publisher $publisher) {
+ return new Migration($publisher);
+ }, ['publisher']);
+ $container->set('queueForStatsResources', function (Publisher $publisher) {
+ return new StatsResources($publisher);
+ }, ['publisher']);
+
+ $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $adapter = new DatabasePool($pools->get('console'));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setNamespace('_console')
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', 'console')
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+
+ $database->setDocumentType('users', User::class);
+
+ return $database;
+ }, ['pools', 'cache', 'authorization']);
+
+ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
+ $adapters = [];
+
+ return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$adapters) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ $database = $project->getAttribute('database', '');
+ if (empty($database)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
+ }
+
+ try {
+ $dsn = new DSN($database);
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $database);
+ }
+
+ $adapter = $adapters[$dsn->getHost()] ??= new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', $project->getId())
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES)
+ ->setDocumentType('users', User::class);
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ return $database;
+ };
+ }, ['pools', 'dbForPlatform', 'cache', 'authorization']);
+
+ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $adapter = null;
+
+ return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
+ $adapter ??= new DatabasePool($pools->get('logs'));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setSharedTables(true)
+ ->setNamespace('logsV1')
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+
+ if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
+ $database->setTenant($project->getSequence());
+ }
+
+ return $database;
+ };
+ }, ['pools', 'cache', 'authorization']);
+
+ /**
+ * List of allowed request hostnames for the request.
+ */
+ $container->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
+ $allowed = [...($platform['hostnames'] ?? [])];
+
+ /* Add platform configured hostnames */
+ 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()) {
+ $allowed[] = $request->getHostname();
+ }
+
+ $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST);
+ $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST);
+
+ $hostname = $originHostname;
+ if (empty($hostname)) {
+ $hostname = $refererHostname;
+ }
+
+ /* Add request hostname for preflight requests */
+ if ($request->getMethod() === 'OPTIONS') {
+ $allowed[] = $hostname;
+ }
+
+ /* Allow the request origin of rule */
+ 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)) {
+ $allowed[] = $hostname;
+ }
+
+ return array_unique($allowed);
+ }, ['platform', 'project', 'rule', 'devKey', 'request']);
+
+ /**
+ * List of allowed request schemes for the request.
+ */
+ $container->set('allowedSchemes', function (array $platform, Document $project) {
+ $allowed = [...($platform['schemas'] ?? [])];
+
+ if (! $project->isEmpty() && $project->getId() !== 'console') {
+ /* Add hardcoded schemes */
+ $allowed[] = 'exp';
+ $allowed[] = 'appwrite-callback-' . $project->getId();
+
+ /* Add platform configured schemes */
+ $platforms = $project->getAttribute('platforms', []);
+ $schemes = Platform::getSchemes($platforms);
+ $allowed = [...$allowed, ...$schemes];
+ }
+
+ return array_unique($allowed);
+ }, ['platform', 'project']);
+
+ /**
+ * Whether the request origin is verified against the request hostname.
+ */
+ $container->set('domainVerification', function (Request $request) {
+ $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
+ $selfDomain = new Domain($request->getHostname());
+ $endDomain = new Domain((string) $origin);
+
+ return ($selfDomain->getRegisterable() === $endDomain->getRegisterable())
+ && $endDomain->getRegisterable() !== '';
+ }, ['request']);
+
+ /**
+ * Cookie domain for the current request.
+ */
+ $container->set('cookieDomain', function (Request $request, Document $project) {
+ $localHosts = ['localhost', 'localhost:' . $request->getPort()];
+
+ $migrationHost = System::getEnv('_APP_MIGRATION_HOST');
+ if (!empty($migrationHost)) {
+ // Treat the migration host like localhost because internal migration and CI
+ // traffic may use it before a public domain is configured.
+ $localHosts[] = $migrationHost;
+ $localHosts[] = $migrationHost . ':' . $request->getPort();
+ }
+
+ $hostname = $request->getHostname();
+ $isLocalHost = \in_array($hostname, $localHosts, true);
+ $isIpAddress = \filter_var($hostname, FILTER_VALIDATE_IP) !== false;
+
+ if ($isLocalHost || $isIpAddress) {
+ return;
+ }
+
+ $isConsoleProject = $project->getAttribute('$id', '') === 'console';
+ $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled';
+
+ if ($isConsoleProject && $isConsoleRootSession) {
+ $domain = new Domain($hostname);
+
+ return '.' . $domain->getRegisterable();
+ }
+
+ return '.' . $hostname;
+ }, ['request', 'project']);
+
+ /**
+ * Rule associated with a request origin.
+ */
+ $container->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
+ $domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
+
+ if (empty($domain)) {
+ $domain = \parse_url($request->getReferer(), PHP_URL_HOST);
+ }
+
+ if (empty($domain)) {
+ return new Document();
+ }
+
+ // TODO: (@Meldiron) Remove after 1.7.x migration
+ $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
+ $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
+ if ($isMd5) {
+ return $dbForPlatform->getDocument('rules', md5($domain));
+ }
+
+ return $dbForPlatform->findOne('rules', [
+ Query::equal('domain', [$domain]),
+ ]) ?? new Document();
+ });
+
+ $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
+
+ // 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', ''))) {
+ $trustedProjects = [];
+ foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
+ if (empty($trustedProject)) {
+ continue;
+ }
+ $trustedProjects[] = $trustedProject;
+ }
+ if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) {
+ $permitsCurrentProject = true;
+ }
+ }
+
+ if (! $permitsCurrentProject) {
+ return new Document();
+ }
+
+ return $rule;
+ }, ['request', 'dbForPlatform', 'project', 'authorization']);
+
+ /**
+ * CORS service
+ */
+ $container->set('cors', function (array $allowedHostnames) {
+ $corsConfig = Config::getParam('cors');
+
+ return new Cors(
+ $allowedHostnames,
+ allowedMethods: $corsConfig['allowedMethods'],
+ allowedHeaders: $corsConfig['allowedHeaders'],
+ allowCredentials: true,
+ exposedHeaders: $corsConfig['exposedHeaders'],
+ );
+ }, ['allowedHostnames']);
+
+ $container->set('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
+ if (! $devKey->isEmpty()) {
+ return new URL();
+ }
+
+ return new Origin($allowedHostnames, $allowedSchemes);
+ }, ['devKey', 'allowedHostnames', 'allowedSchemes']);
+
+ $container->set('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
+ if (! $devKey->isEmpty()) {
+ return new URL();
+ }
+
+ return new Redirect($allowedHostnames, $allowedSchemes);
+ }, ['devKey', 'allowedHostnames', 'allowedSchemes']);
+
+ $container->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
+ /**
+ * Handles user authentication and session validation.
+ *
+ * This function follows a series of steps to determine the appropriate user session
+ * based on cookies, headers, and JWT tokens.
+ *
+ * Process:
+ * 1. Checks the cookie based on mode:
+ * - If in admin mode, uses console project id for key.
+ * - Otherwise, sets the key using the project ID
+ * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`.
+ * - If this method is used, returns the header: `X-Debug-Fallback: true`.
+ * 3. Fetches the user document from the appropriate database based on the mode.
+ * 4. If the user document is empty or the session key cannot be verified, sets an empty user document.
+ * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token.
+ * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`,
+ * 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 ($mode === APP_MODE_ADMIN) {
+ $store->setKey('a_session_' . $console->getId());
+ }
+
+ $store->decode(
+ $request->getCookie(
+ $store->getKey(), // Get sessions
+ $request->getCookie($store->getKey() . '_legacy', '')
+ )
+ );
+
+ // Get session from header for SSR clients
+ if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
+ $sessionHeader = $request->getHeader('x-appwrite-session', '');
+
+ if (! empty($sessionHeader)) {
+ $store->decode($sessionHeader);
+ }
+ }
+
+ // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
+ if ($response) { // if in http context - add debug header
+ $response->addHeader('X-Debug-Fallback', 'false');
+ }
+
+ if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
+ if ($response) {
+ $response->addHeader('X-Debug-Fallback', 'true');
+ }
+ $fallback = $request->getHeader('x-fallback-cookies', '');
+ $fallback = \json_decode($fallback, true);
+ $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
+ }
+
+ $user = null;
+ 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 ($project->getId() === 'console') {
+ /** @var User $user */
+ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
+ } else {
+ /** @var User $user */
+ $user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
+ }
+ }
+ }
+ }
+
+ if (
+ ! $user ||
+ $user->isEmpty() // Check a document has been found in the DB
+ || ! $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()) {
+ throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
+ }
+
+ $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
+ try {
+ $payload = $jwt->decode($authJWT);
+ } catch (JWTException $error) {
+ throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
+ }
+
+ $jwtUserId = $payload['userId'] ?? '';
+ if (! empty($jwtUserId)) {
+ if ($mode === APP_MODE_ADMIN) {
+ $user = $dbForPlatform->getDocument('users', $jwtUserId);
+ } else {
+ $user = $dbForProject->getDocument('users', $jwtUserId);
+ }
+ }
+ $jwtSessionId = $payload['sessionId'] ?? '';
+ if (! empty($jwtSessionId)) {
+ if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
+ $user = new User([]);
+ }
+ }
+ }
+
+ // 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()) {
+ throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
+ }
+
+ $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
+ if (! $accountKeyUser->isEmpty()) {
+ $key = $accountKeyUser->find(
+ key: 'secret',
+ find: $accountKey,
+ subject: 'keys'
+ );
+
+ if (! empty($key)) {
+ $expire = $key->getAttribute('expire');
+ if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
+ throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
+ }
+
+ $user = $accountKeyUser;
+ }
+ }
+ }
+
+ // Impersonation: if current user has impersonator capability and headers are set, act as another user
+ $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
+ $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
+ $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
+ if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
+ $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
+ $targetUser = null;
+ if (!empty($impersonateUserId)) {
+ $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId));
+ } elseif (!empty($impersonateEmail)) {
+ $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])]));
+ } elseif (!empty($impersonatePhone)) {
+ $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])]));
+ }
+ if ($targetUser !== null && !$targetUser->isEmpty()) {
+ $impersonator = clone $user;
+ $user = clone $targetUser;
+ $user->setAttribute('impersonatorUserId', $impersonator->getId());
+ $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence());
+ $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', ''));
+ $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', ''));
+ $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0));
+ }
+ }
+
+ $dbForProject->setMetadata('user', $user->getId());
+ $dbForPlatform->setMetadata('user', $user->getId());
+
+ return $user;
+ }, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
+
+ $container->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
+ /** @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)) {
+ $projectId = $request->getHeader('x-appwrite-project', '');
+ }
+
+ // Backwards compatibility for new services, originally project resources
+ // These endpoints moved from /v1/projects/:projectId/ to /v1/
+ // When accessed via the old alias path, extract projectId from the URI
+ $deprecatedProjectPathPrefix = '/v1/projects/';
+ $route = $utopia->match($request);
+ if (!empty($route)) {
+ $isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) &&
+ !\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix);
+
+ if ($isDeprecatedAlias) {
+ $projectId = \explode('/', $request->getURI(), 5)[3] ?? '';
+ }
+ }
+
+ if (empty($projectId) || $projectId === 'console') {
+ return $console;
+ }
+
+ $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
+
+ return $project;
+ }, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
+
+ $container->set('session', function (User $user, Store $store, Token $proofForToken) {
+ if ($user->isEmpty()) {
+ return;
+ }
+
+ $sessions = $user->getAttribute('sessions', []);
+ $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
+
+ if (! $sessionId) {
+ return;
+ }
+ foreach ($sessions as $session) {
+ /** @var Document $session */
+ if ($sessionId === $session->getId()) {
+ return $session;
+ }
+ }
+
+ return;
+ }, ['user', 'store', 'proofForToken']);
+
+ $container->set('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, Request $request) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ $database = $project->getAttribute('database', '');
+ if (empty($database)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
+ }
+
+ try {
+ $dsn = new DSN($database);
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $database);
+ }
+
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', $project->getId())
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+ $database->setDocumentType('users', User::class);
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ /**
+ * This isolated event handling for `users.*.create` which is based on a `Database::EVENT_DOCUMENT_CREATE` listener may look odd, but it is **intentional**.
+ *
+ * Accounts can be created in many ways beyond `createAccount`
+ * (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here.
+ */
+ $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
+ // Only trigger events for user creation with the database listener.
+ if ($document->getCollection() !== 'users') {
+ return;
+ }
+
+ $queueForEvents
+ ->setEvent('users.[userId].create')
+ ->setParam('userId', $document->getId())
+ ->setPayload($response->output($document, Response::MODEL_USER));
+
+ // Trigger functions, webhooks, and realtime events
+ $queueForFunctions
+ ->from($queueForEvents)
+ ->trigger();
+
+ /** Trigger webhooks events only if a project has them enabled */
+ if (! empty($project->getAttribute('webhooks'))) {
+ $queueForWebhooks
+ ->from($queueForEvents)
+ ->trigger();
+ }
+
+ /** Trigger realtime events only for non console events */
+ if ($queueForEvents->getProject()->getId() !== 'console') {
+ $queueForRealtime
+ ->from($queueForEvents)
+ ->trigger();
+ }
+ };
+
+ /**
+ * Purge function events cache when functions are created, updated or deleted.
+ */
+ $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) {
+
+ if ($document->getCollection() !== 'functions') {
+ return;
+ }
+
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return;
+ }
+
+ $hostname = $dbForProject->getAdapter()->getHostname();
+ $cacheKey = \sprintf(
+ '%s-cache-%s:%s:%s:project:%s:functions:events',
+ $dbForProject->getCacheName(),
+ $hostname,
+ $dbForProject->getNamespace(),
+ $dbForProject->getTenant(),
+ $project->getId()
+ );
+
+ $dbForProject->getCache()->purge($cacheKey);
+ };
+
+ /**
+ * Prefix metrics with database type when applicable.
+ * Avoids prefixing for legacy and tablesdb types to preserve historical metrics.
+ */
+ $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string {
+ if (
+ $databaseType === '' ||
+ $databaseType === DATABASE_TYPE_LEGACY ||
+ $databaseType === DATABASE_TYPE_TABLESDB
+ ) {
+ return $metric;
+ }
+
+ return $databaseType . '.' . $metric;
+ };
+
+ // Determine database type from request path, similar to api.php
+ $path = $request->getURI();
+ $databaseType = match (true) {
+ str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
+ str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
+ default => '',
+ };
+
+ $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) use ($getDatabaseTypePrefixedMetric, $databaseType) {
+ $value = 1;
+
+ switch ($event) {
+ case Database::EVENT_DOCUMENT_DELETE:
+ $value = -1;
+ break;
+ case Database::EVENT_DOCUMENTS_DELETE:
+ $value = -1 * $document->getAttribute('modified', 0);
+ break;
+ case Database::EVENT_DOCUMENTS_CREATE:
+ $value = $document->getAttribute('modified', 0);
+ break;
+ case Database::EVENT_DOCUMENTS_UPSERT:
+ $value = $document->getAttribute('created', 0);
+ break;
+ }
+
+ switch (true) {
+ case $document->getCollection() === 'teams':
+ $usage->addMetric(METRIC_TEAMS, $value); // per project
+ break;
+ case $document->getCollection() === 'users':
+ $usage->addMetric(METRIC_USERS, $value); // per project
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage->addReduce($document);
+ }
+ break;
+ case $document->getCollection() === 'sessions': // sessions
+ $usage->addMetric(METRIC_SESSIONS, $value); // per project
+ break;
+ case $document->getCollection() === 'databases': // databases
+ $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES);
+ $usage->addMetric($metric, $value); // per project
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage->addReduce($document);
+ }
+ break;
+ case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS);
+ $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS);
+ $usage
+ ->addMetric($collectionMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value);
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage->addReduce($document);
+ }
+ break;
+ case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS);
+ $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS);
+ $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS);
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ break;
+ case $document->getCollection() === 'buckets': // buckets
+ $usage->addMetric(METRIC_BUCKETS, $value); // per project
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage
+ ->addReduce($document);
+ }
+ break;
+ case str_starts_with($document->getCollection(), 'bucket_'): // files
+ $parts = explode('_', $document->getCollection());
+ $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':
+ $usage->addMetric(METRIC_FUNCTIONS, $value); // per project
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage
+ ->addReduce($document);
+ }
+ break;
+ case $document->getCollection() === 'sites':
+ $usage->addMetric(METRIC_SITES, $value); // per project
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage
+ ->addReduce($document);
+ }
+ break;
+ case $document->getCollection() === 'deployments':
+ $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
+ ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value)
+ ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS), $value) // per function
+ ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value);
+ break;
+ default:
+ break;
+ }
+ };
+
+ // Clone the queues, to prevent events triggered by the database listener
+ // from overwriting the events that are supposed to be triggered in the shutdown hook.
+ $queueForEventsClone = new Event($publisher);
+ $queueForFunctions = new Func($publisherFunctions);
+ $queueForWebhooks = new Webhook($publisherWebhooks);
+ $queueForRealtime = new Realtime();
+
+ $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', 'usage', 'authorization', 'request']);
+
+ $container->set('schema', function ($utopia, $dbForProject, $authorization) {
+
+ $complexity = function (int $complexity, array $args) {
+ $queries = Query::parseQueries($args['queries'] ?? []);
+ $query = Query::getByType($queries, [Query::TYPE_LIMIT])[0] ?? null;
+ $limit = $query ? $query->getValue() : APP_LIMIT_LIST_DEFAULT;
+
+ return $complexity * $limit;
+ };
+
+ $attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) {
+ $attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [
+ Query::limit($limit),
+ Query::offset($offset),
+ ]));
+
+ return \array_map(function ($attr) {
+ return $attr->getArrayCopy();
+ }, $attrs);
+ };
+
+ $urls = [
+ 'list' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents";
+ },
+ 'create' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents";
+ },
+ 'read' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
+ },
+ 'update' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
+ },
+ 'delete' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
+ },
+ ];
+
+ // NOTE: `params` and `urls` are not used internally in the `Schema::build` function below!
+ $params = [
+ 'list' => function (string $databaseId, string $collectionId, array $args) {
+ return ['queries' => $args['queries']];
+ },
+ 'create' => function (string $databaseId, string $collectionId, array $args) {
+ $id = $args['id'] ?? 'unique()';
+ $permissions = $args['permissions'] ?? null;
+
+ unset($args['id']);
+ unset($args['permissions']);
+
+ // Order must be the same as the route params
+ return [
+ 'databaseId' => $databaseId,
+ 'documentId' => $id,
+ 'collectionId' => $collectionId,
+ 'data' => $args,
+ 'permissions' => $permissions,
+ ];
+ },
+ 'update' => function (string $databaseId, string $collectionId, array $args) {
+ $documentId = $args['id'];
+ $permissions = $args['permissions'] ?? null;
+
+ unset($args['id']);
+ unset($args['permissions']);
+
+ // Order must be the same as the route params
+ return [
+ 'databaseId' => $databaseId,
+ 'collectionId' => $collectionId,
+ 'documentId' => $documentId,
+ 'data' => $args,
+ 'permissions' => $permissions,
+ ];
+ },
+ ];
+
+ return Schema::build(
+ $utopia,
+ $complexity,
+ $attributes,
+ $urls,
+ $params,
+ );
+ }, ['utopia', 'dbForProject', 'authorization']);
+
+ $container->set('audit', function ($dbForProject) {
+ $adapter = new AdapterDatabase($dbForProject);
+
+ return new Audit($adapter);
+ }, ['dbForProject']);
+
+ $container->set('mode', function ($request, Document $project) {
+ /** @var Appwrite\Utopia\Request $request */
+
+ /**
+ * Defines the mode for the request:
+ * - 'default' => Requests for Client and Server Side
+ * - 'admin' => Request from the Console on non-console projects
+ */
+ $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT));
+
+ $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', ''));
+ if (!empty($projectId) && $project->getId() !== $projectId) {
+ $mode = APP_MODE_ADMIN;
+ }
+
+ return $mode;
+ }, ['request', 'project']);
+
+ $container->set('requestTimestamp', function ($request) {
+ // TODO: Move this to the Request class itself
+ $timestampHeader = $request->getHeader('x-appwrite-timestamp');
+ $requestTimestamp = null;
+ 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']);
+
+ $container->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
+ $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
+
+ // Check if given key match project's development keys
+ $key = $project->find('secret', $devKey, 'devKeys');
+ if (! $key) {
+ return new Document([]);
+ }
+
+ // check expiration
+ $expire = $key->getAttribute('expire');
+ if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
+ return new Document([]);
+ }
+
+ // update access time
+ $accessedAt = $key->getAttribute('accessedAt', 0);
+ if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
+ $key->setAttribute('accessedAt', DatabaseDateTime::now());
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
+ 'accessedAt' => $key->getAttribute('accessedAt')
+ ])));
+ $dbForPlatform->purgeCachedDocument('projects', $project->getId());
+ }
+
+ // add sdk to key
+ $sdkValidator = new WhiteList($servers, true);
+ $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
+
+ if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
+ $sdks = $key->getAttribute('sdks', []);
+
+ if (! in_array($sdk, $sdks)) {
+ $sdks[] = $sdk;
+ $key->setAttribute('sdks', $sdks);
+
+ /** Update access time as well */
+ $key->setAttribute('accessedAt', DatabaseDateTime::now());
+ $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
+ 'sdks' => $key->getAttribute('sdks'),
+ 'accessedAt' => $key->getAttribute('accessedAt')
+ ])));
+ $dbForPlatform->purgeCachedDocument('projects', $project->getId());
+ }
+ }
+
+ return $key;
+ }, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
+
+ $container->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
+ $teamInternalId = '';
+ if ($project->getId() !== 'console') {
+ $teamInternalId = $project->getAttribute('teamInternalId', '');
+ } else {
+ $route = $utopia->match($request);
+ $path = ! empty($route) ? $route->getPath() : $request->getURI();
+ $orgHeader = $request->getHeader('x-appwrite-organization', '');
+ if (str_starts_with($path, '/v1/projects/:projectId')) {
+ $uri = $request->getURI();
+ $pid = explode('/', $uri)[3];
+ $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid));
+ $teamInternalId = $p->getAttribute('teamInternalId', '');
+ } elseif ($path === '/v1/projects') {
+ $teamId = $request->getParam('teamId', '');
+
+ if (empty($teamId)) {
+ return new Document([]);
+ }
+
+ $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));
+
+ return $team;
+ } elseif (! empty($orgHeader)) {
+ return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
+ }
+ }
+
+ // if teamInternalId is empty, return an empty document
+
+ if (empty($teamInternalId)) {
+ return new Document([]);
+ }
+
+ $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) {
+ return $dbForPlatform->findOne('teams', [
+ Query::equal('$sequence', [$teamInternalId]),
+ ]);
+ });
+
+ return $team;
+ }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
+
+ $container->set('previewHostname', function (Request $request, ?Key $apiKey) {
+ $allowed = false;
+
+ if (Http::isDevelopment()) {
+ $allowed = 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)) {
+ return $host;
+ }
+ }
+
+ return '';
+ }, ['request', 'apiKey']);
+
+ $container->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
+ $key = $request->getHeader('x-appwrite-key');
+
+ if (empty($key)) {
+ return null;
+ }
+
+ $key = Key::decode($project, $team, $user, $key);
+
+ $userHeader = $request->getHeader('x-appwrite-user');
+ $organizationHeader = $request->getHeader('x-appwrite-organization');
+ $projectHeader = $request->getHeader('x-appwrite-project');
+
+ if (! empty($key->getProjectId())) {
+ if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) {
+ throw new Exception(Exception::PROJECT_ID_MISSING);
+ }
+ }
+
+ if (! empty($key->getUserId())) {
+ if (empty($userHeader) || $userHeader !== $key->getUserId()) {
+ throw new Exception(Exception::USER_ID_MISSING);
+ }
+ }
+
+ if (! empty($key->getTeamId())) {
+ if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) {
+ throw new Exception(Exception::ORGANIZATION_ID_MISSING);
+ }
+ }
+
+ return $key;
+ }, ['request', 'project', 'team', 'user']);
+
+ $container->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
+ $tokenJWT = $request->getParam('token');
+
+ 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.
+
+ try {
+ $payload = $jwt->decode($tokenJWT);
+ } catch (JWTException $error) {
+ return new Document([]);
+ }
+
+ $tokenId = $payload['tokenId'] ?? '';
+ if (empty($tokenId)) {
+ return new Document([]);
+ }
+
+ $token = $authorization->skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId));
+
+ if ($token->isEmpty()) {
+ return new Document([]);
+ }
+
+ $expiry = $token->getAttribute('expire');
+
+ if ($expiry !== null) {
+ $now = new \DateTime();
+ $expiryDate = new \DateTime($expiry);
+
+ if ($expiryDate < $now) {
+ return new Document([]);
+ }
+ }
+
+ return match ($token->getAttribute('resourceType')) {
+ TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject, $authorization) {
+ $sequences = explode(':', $token->getAttribute('resourceInternalId'));
+ $ids = explode(':', $token->getAttribute('resourceId'));
+
+ if (count($sequences) !== 2 || count($ids) !== 2) {
+ return new Document([]);
+ }
+
+ $accessedAt = $token->getAttribute('accessedAt', 0);
+ if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) {
+ $token->setAttribute('accessedAt', DatabaseDateTime::now());
+ $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([
+ 'accessedAt' => $token->getAttribute('accessedAt')
+ ])));
+ }
+
+ return new Document([
+ 'bucketId' => $ids[0],
+ 'fileId' => $ids[1],
+ 'bucketInternalId' => $sequences[0],
+ 'fileInternalId' => $sequences[1],
+ ]);
+ })(),
+
+ default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID),
+ };
+ }
+
+ return new Document([]);
+ }, ['project', 'dbForProject', 'request', 'authorization']);
+
+ $container->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
+
+ return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database {
+ $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
+ $databaseType = $database->getAttribute('type', '');
+
+ try {
+ $databaseDSN = new DSN($databaseDSN);
+ } catch (\InvalidArgumentException) {
+ // for old databases migrated through patch script
+ // databaseDSN determines the adapter
+ $databaseDSN = new DSN('mysql://' . $databaseDSN);
+ }
+ try {
+ $dsn = new DSN($project->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $project->getAttribute('database'));
+ }
+
+ $databaseHost = $databaseDSN->getHost();
+ $pool = $pools->get($databaseHost);
+
+ $adapter = new DatabasePool($pool);
+ $database = new Database($adapter, $cache);
+ $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', $project->getId())
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+ // inside pools authorization needs to be set first
+ $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
+
+ // For separate pools (documentsdb/vectorsdb), check their own shared tables config.
+ // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
+ if ($databaseHost !== $dsn->getHost()) {
+ $dbTypeSharedTables = match ($databaseType) {
+ DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))),
+ VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))),
+ default => [],
+ };
+
+ if (\in_array($databaseHost, $dbTypeSharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($databaseDSN->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+ } elseif (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+ $timeout = \intval($request->getHeader('x-appwrite-timeout'));
+ if (!empty($timeout) && Http::isDevelopment()) {
+ $database->setTimeout($timeout);
+ }
+
+ // Register database event listeners for usage stats collection
+ $documentsMetric = METRIC_DOCUMENTS;
+ $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS;
+ $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS;
+ if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) {
+ $documentsMetric = $databaseType . '.' . $documentsMetric;
+ $databaseIdDocumentsMetric = $databaseType . '.' . $databaseIdDocumentsMetric;
+ $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric;
+ }
+ $database
+ ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = 1;
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = -1;
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = $document->getAttribute('modified', 0);
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = -1 * $document->getAttribute('modified', 0);
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = $document->getAttribute('created', 0);
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ });
+
+ return $database;
+ };
+
+ }, ['pools', 'cache', 'project', 'request', 'usage', 'authorization']);
+
+ $container->set('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) {
+ return new TransactionState($dbForProject, $authorization, $getDatabasesDB);
+ }, ['dbForProject', 'authorization', 'getDatabasesDB']);
+
+ $container->set('executionsRetentionCount', function (Document $project, array $plan) {
+ if ($project->getId() === 'console' || empty($plan)) {
+ return 0;
+ }
+
+ return (int) ($plan['executionsRetentionCount'] ?? 100);
+ }, ['project', 'plan']);
+
+ $container->set('deviceForFiles', function ($project, Telemetry $telemetry) {
+ return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+ $container->set('deviceForSites', function ($project, Telemetry $telemetry) {
+ return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+ $container->set('deviceForMigrations', function ($project, Telemetry $telemetry) {
+ return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+ $container->set('deviceForFunctions', function ($project, Telemetry $telemetry) {
+ return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+ $container->set('deviceForBuilds', function ($project, Telemetry $telemetry) {
+ return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('embeddingAgent', function ($register) {
+ $adapter = new Ollama();
+ $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
+ $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
+ return new Agent($adapter);
+ }, ['register']);
+};
diff --git a/app/init/worker/message.php b/app/init/worker/message.php
new file mode 100644
index 0000000000..95477088ce
--- /dev/null
+++ b/app/init/worker/message.php
@@ -0,0 +1,456 @@
+set('log', fn () => new Log(), []);
+
+ $container->set('usage', fn () => new Context(), []);
+
+ $container->set('authorization', function () {
+ $authorization = new Authorization();
+ $authorization->disable();
+
+ return $authorization;
+ }, []);
+
+ $container->set('dbForPlatform', function (Cache $cache, Group $pools, Authorization $authorization) {
+ $adapter = new DatabasePool($pools->get('console'));
+ $dbForPlatform = new Database($adapter, $cache);
+
+ $dbForPlatform
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setNamespace('_console')
+ ->setDocumentType('users', User::class);
+
+ return $dbForPlatform;
+ }, ['cache', 'pools', 'authorization']);
+
+ $container->set('project', function ($message, Database $dbForPlatform) {
+ $payload = $message->getPayload() ?? [];
+ $project = new Document($payload['project'] ?? []);
+
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $project;
+ }
+
+ return $dbForPlatform->getDocument('projects', $project->getId());
+ }, ['message', 'dbForPlatform']);
+
+ $container->set('dbForProject', function (Cache $cache, Group $pools, Document $project, Database $dbForPlatform, Authorization $authorization) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ try {
+ $dsn = new DSN($project->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $project->getAttribute('database'));
+ }
+
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+ $database->setDocumentType('users', User::class);
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
+
+ return $database;
+ }, ['cache', 'pools', 'project', 'dbForPlatform', 'authorization']);
+
+ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
+ $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
+
+ return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ try {
+ $dsn = new DSN($project->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $project->getAttribute('database'));
+ }
+
+ if (isset($databases[$dsn->getHost()])) {
+ $database = $databases[$dsn->getHost()];
+ $database->setAuthorization($authorization);
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ return $database;
+ }
+
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+
+ $databases[$dsn->getHost()] = $database;
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
+
+ return $database;
+ };
+ }, ['pools', 'dbForPlatform', 'cache', 'authorization']);
+
+ $container->set('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) {
+ return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database {
+ $projectDocument ??= $project;
+ $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
+ $databaseType = $database->getAttribute('type', '');
+
+ // Backwards-compatibility: older or seeded legacy databases may not have a DSN stored
+ // in the "database" attribute. In that case, fall back to the project's database DSN.
+ if ($databaseDSN === '') {
+ $databaseDSN = $projectDocument->getAttribute('database', '');
+ }
+
+ try {
+ $databaseDSN = new DSN($databaseDSN);
+ } catch (\InvalidArgumentException) {
+ $databaseDSN = new DSN('mysql://' . $databaseDSN);
+ }
+
+ try {
+ $dsn = new DSN($projectDocument->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // Temporary fallback until all projects use shared tables
+ $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database'));
+ }
+
+ $pools = $register->get('pools');
+ $databaseHost = $databaseDSN->getHost();
+ $pool = $pools->get($databaseHost);
+
+ $adapter = new DatabasePool($pool);
+ $database = new Database($adapter, $cache);
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization);
+ $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
+
+ $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
+
+ // For separate pools (documentsdb/vectorsdb), check their own shared tables config.
+ // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
+ if ($databaseHost !== $dsn->getHost()) {
+ $dbTypeSharedTables = match ($databaseType) {
+ DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))),
+ VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))),
+ default => [],
+ };
+
+ if (\in_array($databaseHost, $dbTypeSharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($projectDocument->getSequence())
+ ->setNamespace($databaseDSN->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $projectDocument->getSequence());
+ }
+ } elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
+ $database
+ ->setSharedTables(true)
+ ->setTenant($projectDocument->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $projectDocument->getSequence());
+ }
+
+ $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
+ return $database;
+ };
+ }, ['cache', 'register', 'project', 'authorization']);
+
+ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $database = null;
+
+ return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
+ if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
+ $database->setTenant($project->getSequence());
+
+ return $database;
+ }
+
+ $adapter = new DatabasePool($pools->get('logs'));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setSharedTables(true)
+ ->setNamespace('logsV1')
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
+
+ if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
+ $database->setTenant($project->getSequence());
+ }
+
+ return $database;
+ };
+ }, ['pools', 'cache', 'authorization']);
+
+ $container->set('abuseRetention', function () {
+ return \time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day
+ }, []);
+
+ $container->set('auditRetention', function (Document $project) {
+ if ($project->getId() === 'console') {
+ return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
+ }
+
+ return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
+ }, ['project']);
+
+ $container->set('executionRetention', function () {
+ return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
+ }, []);
+
+ $container->set('queueForDatabase', function (Publisher $publisher) {
+ return new EventDatabase($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForMessaging', function (Publisher $publisher) {
+ return new Messaging($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForMails', function (Publisher $publisher) {
+ return new Mail($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForBuilds', function (Publisher $publisher) {
+ return new Build($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForScreenshots', function (Publisher $publisher) {
+ return new Screenshot($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForDeletes', function (Publisher $publisher) {
+ return new Delete($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForEvents', function (Publisher $publisher) {
+ return new Event($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForAudits', function (Publisher $publisher) {
+ return new Audit($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForWebhooks', function (Publisher $publisher) {
+ return new Webhook($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForFunctions', function (Publisher $publisher) {
+ return new Func($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForRealtime', function () {
+ return new Realtime();
+ }, []);
+
+ $container->set('queueForCertificates', function (Publisher $publisher) {
+ return new Certificate($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForMigrations', function (Publisher $publisher) {
+ return new Migration($publisher);
+ }, ['publisher']);
+
+ $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForFiles', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForCache', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('logError', function (Registry $register, Document $project) {
+ return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) {
+ $logger = $register->get('logger');
+
+ if ($logger) {
+ $version = System::getEnv('_APP_VERSION', 'UNKNOWN');
+
+ $log = new Log();
+ $log->setNamespace($namespace);
+ $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
+ $log->setVersion($version);
+ $log->setType(Log::TYPE_ERROR);
+ $log->setMessage($error->getMessage());
+
+ $log->addTag('code', $error->getCode());
+ $log->addTag('verboseType', \get_class($error));
+ $log->addTag('projectId', $project->getId() ?? '');
+
+ $log->addExtra('file', $error->getFile());
+ $log->addExtra('line', $error->getLine());
+ $log->addExtra('trace', $error->getTraceAsString());
+
+ if ($error->getPrevious() !== null) {
+ if ($error->getPrevious()->getMessage() != $error->getMessage()) {
+ $log->addExtra('previousMessage', $error->getPrevious()->getMessage());
+ }
+ $log->addExtra('previousFile', $error->getPrevious()->getFile());
+ $log->addExtra('previousLine', $error->getPrevious()->getLine());
+ }
+
+ foreach (($extras ?? []) as $key => $value) {
+ $log->addExtra($key, $value);
+ }
+
+ $log->setAction($action);
+
+ $isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
+ $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
+
+ try {
+ $responseCode = $logger->addLog($log);
+ Console::info('Error log pushed with status code: ' . $responseCode);
+ } catch (Throwable $th) {
+ Console::error('Error pushing log: ' . $th->getMessage());
+ }
+ }
+
+ Console::warning("Failed: {$error->getMessage()}");
+ Console::warning($error->getTraceAsString());
+
+ if ($error->getPrevious() !== null) {
+ if ($error->getPrevious()->getMessage() != $error->getMessage()) {
+ Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}");
+ }
+ Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}");
+ }
+ };
+ }, ['register', 'project']);
+
+ $container->set('getAudit', function (Database $dbForPlatform, callable $getProjectDB) {
+ 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']);
+
+ $container->set('executionsRetentionCount', function (Document $project, array $plan) {
+ if ($project->getId() === 'console' || empty($plan)) {
+ return 0;
+ }
+
+ return (int) ($plan['executionsRetentionCount'] ?? 100);
+ }, ['project', 'plan']);
+};
diff --git a/app/realtime.php b/app/realtime.php
index d3305ca7f8..97ea7e32d5 100644
--- a/app/realtime.php
+++ b/app/realtime.php
@@ -33,7 +33,9 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
+use Utopia\DI\Container;
use Utopia\DSN\DSN;
+use Utopia\Http\Adapter\FPM\Server as HttpServer;
use Utopia\Http\Http;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
@@ -48,6 +50,8 @@ use Utopia\WebSocket\Server;
*/
require_once __DIR__ . '/init.php';
+$registerRequestResources ??= require __DIR__ . '/init/resources/request.php';
+
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
// Log uncaught exceptions in one line instead of relying on Swoole's full backtrace dump
@@ -237,10 +241,14 @@ if (!function_exists('getTelemetry')) {
if (!function_exists('triggerStats')) {
function triggerStats(array $event, string $projectId): void
{
- return;
}
}
+global $container;
+$container->set('pools', function ($register) {
+ return $register->get('pools');
+}, ['register']);
+
$realtime = getRealtime();
/**
@@ -320,14 +328,14 @@ if (!function_exists('logError')) {
$server->error(logError(...));
-$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
+$server->onStart(function () use ($stats, $containerId, &$statsDocument) {
sleep(5); // wait for the initial database schema to be ready
Console::success('Server started successfully');
/**
* Create document for this worker to share stats across Containers.
*/
- go(function () use ($register, $containerId, &$statsDocument) {
+ go(function () use ($containerId, &$statsDocument) {
$attempts = 0;
$database = getConsoleDB();
@@ -357,7 +365,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
*/
// TODO: Remove this if check once it doesn't cause issues for cloud
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
- Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
+ Timer::tick(5000, function () use ($stats, &$statsDocument) {
$payload = [];
foreach ($stats as $projectId => $value) {
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
@@ -396,7 +404,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$attempts = 0;
$start = time();
- Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
+ Timer::tick(5000, function () use ($server, $realtime, $stats) {
/**
* Sending current connections to project channels on the console project every 5 seconds.
*/
@@ -518,7 +526,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId));
$database = getProjectDB($project);
- /** @var Appwrite\Utopia\Database\Documents\User $user */
+ /** @var User $user */
$user = $database->getDocument('users', $userId);
$roles = $user->getRoles($database->getAuthorization());
@@ -615,16 +623,22 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::error('Failed to restart pub/sub...');
});
-$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
- $app = new Http('UTC');
+$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerRequestResources) {
+ global $container;
$request = new Request($request);
$response = new Response(new SwooleResponse());
Console::info("Connection open (user: {$connection})");
- Http::setResource('pools', fn () => $register->get('pools'));
- Http::setResource('request', fn () => $request);
- Http::setResource('response', fn () => $response);
+ $connectionContainer = new Container($container);
+
+ $adapter = new HttpServer($connectionContainer);
+ $app = new Http($adapter, 'UTC');
+ $connectionContainer->set('utopia', fn () => $app);
+ $connectionContainer->set('request', fn () => $request);
+ $connectionContainer->set('response', fn () => $response);
+
+ $registerRequestResources($connectionContainer);
$project = null;
$logUser = null;
@@ -642,10 +656,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID');
}
+ $timelimit = $app->getResource('timelimit');
+ $user = $app->getResource('user'); /** @var User $user */
+ $logUser = $user;
+
if (
array_key_exists('realtime', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['realtime']
- && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -656,10 +674,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
}
- $timelimit = $app->getResource('timelimit');
- $user = $app->getResource('user'); /** @var User $user */
- $logUser = $user;
-
/*
* Abuse Check
*
@@ -798,7 +812,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
}
});
-$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
+$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) {
$project = null;
$authorization = null;
@@ -810,7 +824,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
// Get authorization from connection (stored during onOpen)
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
if ($authorization === null) {
- $authorization = new Authorization('');
+ $authorization = new Authorization();
}
$database = getConsoleDB();
diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml
index 05bc1b80ed..d33838c8c6 100644
--- a/app/views/install/installer.phtml
+++ b/app/views/install/installer.phtml
@@ -13,7 +13,7 @@ $enabledDatabases = $enabledDatabases ?? ['mongodb', 'mariadb', 'postgresql'];
$isLocalInstall = $isLocalInstall ?? false;
-$cardStep = min(4, $step);
+$cardStep = ($step === 5) ? 4 : $step;
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
if (!is_file($stepFile)) {
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css
index 7f253eed46..8fd28a12a3 100644
--- a/app/views/install/installer/css/styles.css
+++ b/app/views/install/installer/css/styles.css
@@ -478,6 +478,10 @@ body {
overflow: hidden;
}
+.installer-page[data-upgrade='true'] .installer-step {
+ min-height: 0;
+}
+
.action-shell {
display: flex;
flex-direction: column;
@@ -1808,3 +1812,92 @@ body {
gap: var(--gap-s);
}
}
+
+.migration-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--gap-l);
+ padding: var(--space-6);
+ background: var(--bgcolor-neutral-default);
+ border-radius: var(--border-radius-m);
+ outline: var(--border-width-s) solid var(--border-neutral);
+ outline-offset: calc(var(--border-width-s) * -1);
+ cursor: pointer;
+ transition: outline-color 0.15s ease-in-out;
+}
+
+.migration-option:hover {
+ outline-color: var(--border-neutral-stronger);
+}
+
+.migration-option-content {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.migration-switch {
+ flex-shrink: 0;
+}
+
+.migration-switch-track {
+ position: relative;
+ display: block;
+ width: 32px;
+ height: 20px;
+ border-radius: 10px;
+ background: var(--bgcolor-neutral-invert-weaker);
+ transition: background 0.15s ease-in-out;
+}
+
+.migration-switch-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--bgcolor-neutral-primary);
+ transition: transform 0.15s ease-in-out;
+}
+
+#run-migration:checked ~ .migration-switch-track {
+ background: var(--bgcolor-neutral-invert-weak);
+}
+
+#run-migration:checked ~ .migration-switch-track .migration-switch-thumb {
+ transform: translateX(12px);
+}
+
+#run-migration:focus-visible ~ .migration-switch-track {
+ box-shadow: 0 0 0 var(--border-width-l) var(--border-focus);
+}
+
+.migration-hint {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--gap-s);
+ padding: 0 var(--space-2);
+}
+
+.migration-hint-icon {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ color: var(--fgcolor-neutral-tertiary);
+ margin-top: 1px;
+}
+
+.migration-hint-icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.migration-code {
+ padding: 1px 4px;
+ border-radius: var(--border-radius-xs, 4px);
+ background: var(--bgcolor-neutral-secondary);
+ font-family: monospace;
+ font-size: inherit;
+}
diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js
index 463b7f6221..07ec7bb1ef 100644
--- a/app/views/install/installer/js/installer.js
+++ b/app/views/install/installer/js/installer.js
@@ -12,7 +12,7 @@
const { validateInstallRequest } = window.InstallerStepsProgress || {};
const isUpgrade = document.body?.dataset.upgrade === 'true';
- const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5];
+ const stepFlow = isUpgrade ? [1, 6, 4, 5] : [1, 2, 3, 4, 5];
const cardSteps = stepFlow.filter((step) => step !== 5);
const normalizeStep = (step) => {
@@ -53,7 +53,7 @@
let pendingStep = null;
let pendingPushState = false;
- const clampStep = (step) => Math.max(1, Math.min(5, step));
+ const clampStep = (step) => Math.max(1, Math.min(6, step));
const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.());
const scrollToFirstError = (panel) => {
@@ -399,11 +399,18 @@
}
}
}
- if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') {
- const isValid = await validateInstallRequest();
- if (!isValid) {
- return;
+ if (action === 'next' && String(target) === '5') {
+ if (typeof validateInstallRequest === 'function') {
+ const isValid = await validateInstallRequest();
+ if (!isValid) {
+ return;
+ }
}
+ // Clear stale install data from previous runs so initStep5
+ // starts a fresh install instead of trying to resume.
+ const { clearInstallLock, clearInstallId } = window.InstallerStepsState || {};
+ clearInstallLock?.();
+ clearInstallId?.();
}
if (isInstallLocked() && Number(target) !== 5) {
requestStep(5, true);
diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js
index 4917a1bfe9..6f215da899 100644
--- a/app/views/install/installer/js/modules/context.js
+++ b/app/views/install/installer/js/modules/context.js
@@ -14,6 +14,7 @@
ENV_VARS: 'env-vars',
DOCKER_CONTAINERS: 'docker-containers',
ACCOUNT_SETUP: 'account-setup',
+ MIGRATION: 'migration',
SSL_CERTIFICATE: 'ssl-certificate',
REDIRECT: 'redirect'
});
@@ -52,6 +53,11 @@
id: STEP_IDS.DOCKER_CONTAINERS,
inProgress: 'Restarting Docker containers...',
done: 'Docker containers restarted'
+ },
+ {
+ id: STEP_IDS.MIGRATION,
+ inProgress: 'Running database migration...',
+ done: 'Database migration completed'
}
] : [
{
@@ -95,7 +101,7 @@
const clampStep = (step) => {
const numeric = Number(step);
if (Number.isNaN(numeric)) return 1;
- return Math.max(1, Math.min(5, numeric));
+ return Math.max(1, Math.min(6, numeric));
};
window.InstallerStepsContext = Object.freeze({
diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js
index d066908b03..7c36fd7951 100644
--- a/app/views/install/installer/js/modules/progress.js
+++ b/app/views/install/installer/js/modules/progress.js
@@ -373,7 +373,8 @@
opensslKey: (formState?.opensslKey || '').trim(),
assistantOpenAIKey: normalizedAssistantKey,
accountEmail: normalizedAccountEmail,
- accountPassword: normalizedAccountPassword
+ accountPassword: normalizedAccountPassword,
+ migrate: formState?.migrate ?? false
};
};
@@ -721,7 +722,8 @@
});
startSyncedSpinnerRotation(list);
- notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
+ const completeId = activeInstall?.installId || getStoredInstallId?.();
+ notifyInstallComplete(completeId, sessionDetails).finally(() => {
setTimeout(() => redirectToApp(protocol), TIMINGS?.redirectDelay ?? 0);
});
};
@@ -911,21 +913,28 @@
};
const isSnapshotTerminal = (snapshot) => {
- if (!snapshot?.steps) return true;
+ if (!snapshot?.steps) return 'empty';
const stepEntries = Object.values(snapshot.steps);
- if (stepEntries.length === 0) return true;
+ if (stepEntries.length === 0) return 'empty';
const hasError = stepEntries.some((s) => s.status === STATUS.ERROR);
- if (hasError) return true;
+ if (hasError) return 'error';
const allCompleted = INSTALLATION_STEPS.every((step) => {
const detail = snapshot.steps[step.id];
return detail && detail.status === STATUS.COMPLETED;
});
- return allCompleted;
+ if (allCompleted) return 'completed';
+ return false;
};
const resumeInstall = async (installId) => {
const snapshot = await fetchInstallStatus(installId);
- if (!snapshot || isSnapshotTerminal(snapshot)) return false;
+ const terminal = isSnapshotTerminal(snapshot);
+ if (!snapshot || terminal) {
+ if (terminal === 'completed') {
+ return 'completed';
+ }
+ return false;
+ }
activeInstall = {
installId,
controller: new AbortController(),
@@ -1069,14 +1078,33 @@
startInstallStream(newInstallId);
};
+ const recoverToLastStep = () => {
+ clearInstallId?.();
+ clearInstallLock?.();
+ const url = new URL(window.location.href);
+ const lastStep = url.searchParams.get('step');
+ // Stay on the current URL so the user keeps their place;
+ // only navigate away if we're already on step 5 (the
+ // progress screen) since there's nothing to show.
+ if (!lastStep || String(lastStep) === '5') {
+ window.location.href = '/?step=1';
+ }
+ };
+
const lock = getInstallLock?.();
const existingInstallId = lock?.installId || getStoredInstallId?.();
if (existingInstallId) {
- resumeInstall(existingInstallId).then((resumed) => {
- if (!resumed) {
- clearInstallId?.();
+ resumeInstall(existingInstallId).then((result) => {
+ if (result === 'completed') {
+ // Install already finished — redirect to console
+ // instead of bouncing back to step 1.
+ stopSyncedSpinnerRotation();
+ setUnloadGuard(false);
clearInstallLock?.();
- window.location.href = '/?step=1';
+ clearInstallId?.();
+ startSslCheck(null);
+ } else if (!result) {
+ recoverToLastStep();
}
});
} else {
diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js
index c9430b7afd..b34389b561 100644
--- a/app/views/install/installer/js/steps.js
+++ b/app/views/install/installer/js/steps.js
@@ -329,6 +329,30 @@
}
};
+ const initStep6 = (root) => {
+ if (!root) return;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+
+ const checkbox = root.querySelector('#run-migration');
+ if (checkbox) {
+ if (formState.migrate !== undefined) {
+ checkbox.checked = formState.migrate;
+ } else {
+ formState.migrate = checkbox.checked;
+ }
+ checkbox.addEventListener('change', () => {
+ formState.migrate = checkbox.checked;
+ dispatchStateChange?.('migrate');
+ });
+ }
+
+ if (isInstallLocked?.()) {
+ disableControls?.(root);
+ }
+ };
+
const initStep = (step, container) => {
if (!container) return;
const root = container.querySelector('.step-layout') || container;
@@ -346,6 +370,7 @@
if (normalized === 3) initStep3(root);
if (normalized === 4) initStep4(root);
if (normalized === 5) Progress.initStep5?.(root);
+ if (normalized === 6) initStep6(root);
};
window.InstallerSteps = {
diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml
index 07dc865257..8468de30f4 100644
--- a/app/views/install/installer/templates/steps/step-4.phtml
+++ b/app/views/install/installer/templates/steps/step-4.phtml
@@ -62,12 +62,14 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
Disabled