Merge branch '1.9.x' into feat-public-project-keys

This commit is contained in:
Matej Bačo
2026-04-08 09:45:15 +02:00
237 changed files with 8655 additions and 5686 deletions
+30 -1
View File
@@ -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
+1
View File
@@ -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
+99 -86
View File
@@ -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 <file>` | Format a specific file |
| `composer lint <file>` | 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.
+43 -63
View File
@@ -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)
<img width="1920" height="1080" alt="image" src="https://github.com/user-attachments/assets/55a81268-4ecc-46cd-bdf5-73f7e8662fee" />
<br />
<p align="center">
<a href="https://appwrite.io" target="_blank"><img src="./public/images/banner.png" alt="Appwrite banner, with logo and text saying "The Developer's Cloud"></a>
<br />
<br />
<b>Appwrite is a best-in-class, developer-first platform that gives builders everything they need to create scalable, stable, and production-ready software, fast.</b>
<h1>Appwrite</h1>
<b>Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.</b>
<br />
<br />
</p>
<!-- [![Build Status](https://img.shields.io/travis/com/appwrite/appwrite?style=flat-square)](https://travis-ci.com/appwrite/appwrite) -->
[![We're Hiring label](https://img.shields.io/static/v1?label=We're&message=Hiring&color=blue&style=flat-square)](https://appwrite.io/company/careers)
[![Hacktoberfest label](https://img.shields.io/static/v1?label=hacktoberfest&message=ready&color=191120&style=flat-square)](https://hacktoberfest.appwrite.io)
[![Discord label](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord?r=Github)
[![Build Status label](https://img.shields.io/github/actions/workflow/status/appwrite/appwrite/tests.yml?branch=master&label=tests&style=flat-square)](https://github.com/appwrite/appwrite/actions)
[![X Account label](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite)
<!-- [![Docker Pulls](https://img.shields.io/docker/pulls/appwrite/appwrite?color=f02e65&style=flat-square)](https://hub.docker.com/r/appwrite/appwrite) -->
<!-- [![Translate](https://img.shields.io/badge/translate-f02e65?style=flat-square)](docs/tutorials/add-translations.md) -->
<!-- [![Swag Store](https://img.shields.io/badge/swag%20store-f02e65?style=flat-square)](https://store.appwrite.io) -->
[![Discord](https://img.shields.io/badge/chat-5865F2?style=flat-square&logo=discord&logoColor=white)](https://appwrite.io/discord)
[![X](https://img.shields.io/badge/follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/appwrite)
[![Appwrite Cloud](https://img.shields.io/badge/Cloud-F02E65?style=flat-square&logo=icloud&logoColor=white)](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.
![Appwrite project dashboard showing various Appwrite features](public/images/github.png)
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: &nbsp; [Web](https://github.com/appwrite/sdk-for-web) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Flutter](https://github.com/appwrite/sdk-for-flutter) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Apple](https://github.com/appwrite/sdk-for-apple) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Android](https://github.com/appwrite/sdk-for-android) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [React Native](https://github.com/appwrite/sdk-for-react-native) - **Beta** (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Web](https://github.com/appwrite/sdk-for-web)
- :white_check_mark: &nbsp; [Flutter](https://github.com/appwrite/sdk-for-flutter)
- :white_check_mark: &nbsp; [Apple](https://github.com/appwrite/sdk-for-apple)
- :white_check_mark: &nbsp; [Android](https://github.com/appwrite/sdk-for-android)
- :white_check_mark: &nbsp; [React Native](https://github.com/appwrite/sdk-for-react-native)
#### Server
- :white_check_mark: &nbsp; [NodeJS](https://github.com/appwrite/sdk-for-node) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [PHP](https://github.com/appwrite/sdk-for-php) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Dart](https://github.com/appwrite/sdk-for-dart) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Deno](https://github.com/appwrite/sdk-for-deno) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Ruby](https://github.com/appwrite/sdk-for-ruby) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Python](https://github.com/appwrite/sdk-for-python) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Kotlin](https://github.com/appwrite/sdk-for-kotlin) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Swift](https://github.com/appwrite/sdk-for-swift) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [.NET](https://github.com/appwrite/sdk-for-dotnet) - **Beta** (Maintained by the Appwrite Team)
#### Community
- :white_check_mark: &nbsp; [Appcelerator Titanium](https://github.com/m1ga/ti.appwrite) (Maintained by [Michael Gangolf](https://github.com/m1ga/))
- :white_check_mark: &nbsp; [Godot Engine](https://github.com/GodotNuts/appwrite-sdk) (Maintained by [fenix-hub @GodotNuts](https://github.com/fenix-hub))
- :white_check_mark: &nbsp; [NodeJS](https://github.com/appwrite/sdk-for-node)
- :white_check_mark: &nbsp; [PHP](https://github.com/appwrite/sdk-for-php)
- :white_check_mark: &nbsp; [Dart](https://github.com/appwrite/sdk-for-dart)
- :white_check_mark: &nbsp; [Deno](https://github.com/appwrite/sdk-for-deno)
- :white_check_mark: &nbsp; [Ruby](https://github.com/appwrite/sdk-for-ruby)
- :white_check_mark: &nbsp; [Python](https://github.com/appwrite/sdk-for-python)
- :white_check_mark: &nbsp; [Kotlin](https://github.com/appwrite/sdk-for-kotlin)
- :white_check_mark: &nbsp; [Swift](https://github.com/appwrite/sdk-for-swift)
- :white_check_mark: &nbsp; [.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)!
+39 -39
View File
@@ -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);
+1 -1
View File
@@ -64,7 +64,7 @@ return [
[
'$id' => ID::custom('database'),
'type' => Database::VAR_STRING,
'size' => 128,
'size' => 2000,
'required' => false,
'signed' => true,
'array' => false,
+4
View File
@@ -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)) : [],
+15
View File
@@ -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.',
+15 -15
View File
@@ -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}},",
+35 -24
View File
@@ -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',
@@ -494,6 +486,25 @@ return [
'gitBranch' => 'dev',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/swift/CHANGELOG.md'),
],
[
'key' => 'rust',
'name' => 'Rust',
'version' => '0.1.0',
'url' => 'https://github.com/appwrite/sdk-for-rust',
'package' => 'https://crates.io/crates/appwrite',
'enabled' => true,
'beta' => true,
'dev' => true,
'hidden' => false,
'family' => APP_SDK_PLATFORM_SERVER,
'prism' => 'rust',
'source' => \realpath(__DIR__ . '/../sdks/server-rust'),
'gitUrl' => 'git@github.com:appwrite/sdk-for-rust.git',
'gitRepoName' => 'sdk-for-rust',
'gitUserName' => 'appwrite',
'gitBranch' => 'dev',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/rust/CHANGELOG.md'),
],
[
'key' => 'graphql',
'name' => 'GraphQL',
-3
View File
@@ -31,9 +31,6 @@ class FunctionUseCases
public const DEV_TOOLS = 'dev-tools';
public const AUTH = 'auth';
/**
* @var array<string>
*/
public static function getAll(): array
{
return [
+3 -6
View File
@@ -25,9 +25,6 @@ class SiteUseCases
public const FORMS = 'forms';
public const DASHBOARD = 'dashboard';
/**
* @var array<string>
*/
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',
+294 -99
View File
@@ -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;
@@ -2149,10 +2251,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 +2290,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 +2348,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 +2560,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 +2597,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 +2653,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 +2898,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 +2950,8 @@ Http::put('/v1/account/sessions/phone')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action($createSession);
@@ -3314,9 +3478,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 +3509,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 +3743,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 +3755,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 +3955,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 +4056,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 +4286,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 +4378,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 +4910,5 @@ Http::delete('/v1/account/identities/:identityId')
->setParam('identityId', $identity->getId())
->setPayload($response->output($identity, Response::MODEL_IDENTITY));
return $response->noContent();
$response->noContent();
});
+8 -2
View File
@@ -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);
}
+1
View File
@@ -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([
+1 -1
View File
@@ -3566,7 +3566,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 {
+314 -12
View File
@@ -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')
+1 -1
View File
@@ -1283,7 +1283,7 @@ Http::post('/v1/projects/:projectId/smtp/tests')
->trigger();
}
return $response->noContent();
$response->noContent();
});
Http::get('/v1/projects/:projectId/templates/sms/:type/:locale')
+104 -41
View File
@@ -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)
@@ -1470,8 +1506,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 +1540,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 +2402,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 +2466,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 +2721,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')
+47 -45
View File
@@ -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')
+30 -28
View File
@@ -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(),
]);
+21 -11
View File
@@ -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.
@@ -419,7 +422,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 +486,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 +506,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 +538,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
@@ -611,7 +621,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 +640,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 +673,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 +707,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;
}
@@ -984,7 +994,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,
+4 -3
View File
@@ -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;
+60 -56
View File
@@ -1,14 +1,13 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
$registerRequestResources = require __DIR__ . '/init/resources/request.php';
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Swoole\Constant;
use Swoole\Http\Request as SwooleRequest;
use Swoole\Http\Response as SwooleResponse;
use Swoole\Http\Server;
use Swoole\Process;
use Swoole\Table;
use Swoole\Timer;
@@ -27,11 +26,11 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Http\Adapter\Swoole\Server;
use Utopia\Http\Files;
use Utopia\Http\Http;
use Utopia\Logger\Log;
use Utopia\Logger\Log\User;
use Utopia\Pools\Group;
use Utopia\Span\Span;
use Utopia\System\System;
@@ -48,18 +47,35 @@ $certifiedDomains = new Table(100_000);
$certifiedDomains->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();
+1
View File
@@ -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';
+23 -49
View File
@@ -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');
+38 -1369
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
<?php
use Appwrite\Event\Audit;
use Appwrite\Event\Build;
use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Publisher;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
/**
* Register per-job resources on the given container.
* These resources depend on the queue message or keep mutable state and
* must be fresh for each worker job.
*/
return function (Container $container): void {
$container->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');
$pool = $pools->get($databaseDSN->getHost());
$adapter = new DatabasePool($pool);
$database = new Database($adapter, $cache);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization);
$database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setTenant((int) $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']);
};
+32 -18
View File
@@ -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();
+1 -1
View File
@@ -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";
@@ -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;
}
+13 -6
View File
@@ -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);
@@ -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({
@@ -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 {
+25
View File
@@ -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 = {
@@ -62,12 +62,14 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
</div>
<?php if (!$isUpgrade) { ?>
<div class="review-row">
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
</div>
<?php } ?>
</div>
</div>
</div>
@@ -6,7 +6,7 @@ $isUpgrade = $isUpgrade ?? false;
<div class="install-panel">
<div class="install-header">
<div class="typography-text-m-400 text-neutral-primary">
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
<?php echo $isUpgrade ? 'Updating Appwrite…' : 'Installing Appwrite…'; ?>
</div>
</div>
<div class="install-list" data-install-list></div>
@@ -0,0 +1,37 @@
<?php
$isUpgrade = $isUpgrade ?? false;
?>
<div class="step-layout" data-step="6">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary">Database migration</h1>
<p class="typography-text-m-400 text-neutral-secondary">
Run database migration after the update to apply schema changes.
</p>
</div>
<div class="stack-xl">
<label class="migration-option" for="run-migration">
<span class="migration-option-content">
<span class="typography-text-m-500 text-neutral-primary">Run migration automatically</span>
<span class="typography-text-xs-400 text-neutral-tertiary">Recommended when upgrading to a new version</span>
</span>
<span class="migration-switch">
<input type="checkbox" id="run-migration" name="migrate" class="sr-only" checked>
<span class="migration-switch-track" aria-hidden="true">
<span class="migration-switch-thumb"></span>
</span>
</span>
</label>
<div class="migration-hint">
<span class="migration-hint-icon">
<?php include __DIR__ . '/../../icons/info.svg'; ?>
</span>
<span class="typography-text-xs-400 text-neutral-tertiary">
To run manually later: <code class="migration-code">docker compose exec appwrite migrate</code>
</span>
</div>
</div>
</div>
</div>
+41 -530
View File
@@ -2,564 +2,68 @@
require_once __DIR__ . '/init.php';
$registerWorkerMessageResources = require __DIR__ . '/init/worker/message.php';
use Appwrite\Certificates\LetsEncrypt;
use Appwrite\Event\Audit;
use Appwrite\Event\Build;
use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
use Appwrite\Platform\Appwrite;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Swoole\Runtime;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Logger\Logger;
use Utopia\Platform\Service;
use Utopia\Pools\Group;
use Utopia\Queue\Adapter\Swoole;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Message;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Queue\Server;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
Runtime::enableCoroutine();
require_once __DIR__ . '/init/span.php';
global $register;
Server::setResource('register', fn () => $register);
global $container;
$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
Server::setResource('authorization', function () {
$container->set('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
}, []);
Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) {
$pools = $register->get('pools');
$adapter = new DatabasePool($pools->get('console'));
$dbForPlatform = new Database($adapter, $cache);
$container->set('project', fn () => new Document([]), []);
$dbForPlatform
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setNamespace('_console')
->setDocumentType('users', User::class);
$container->set('log', fn () => new Log(), []);
return $dbForPlatform;
}, ['cache', 'register', 'authorization']);
Server::setResource('project', function (Message $message, Database $dbForPlatform) {
$payload = $message->getPayload() ?? [];
$project = new Document($payload['project'] ?? []);
if ($project->getId() === 'console') {
return $project;
}
return $dbForPlatform->getDocument('projects', $project->getId());
}, ['message', 'dbForPlatform']);
Server::setResource('dbForProject', function (Cache $cache, Registry $register, Message $message, Document $project, Database $dbForPlatform, Authorization $authorization) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
$pools = $register->get('pools');
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', 'register', 'message', 'project', 'dbForPlatform', 'authorization']);
Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $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']);
Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($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']);
Server::setResource('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', '');
// Backwardscompatibility: 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');
$pool = $pools->get($databaseDSN->getHost());
$adapter = new DatabasePool($pool);
$database = new Database($adapter, $cache);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization);
$database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setTenant((int) $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']);
Server::setResource('abuseRetention', function () {
return time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day
});
Server::setResource('auditRetention', function (Document $project) {
if ($project->getId() === 'console') {
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
}
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
}, ['project']);
Server::setResource('executionRetention', function () {
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
});
Server::setResource('cache', function (Registry $register) {
$pools = $register->get('pools');
$list = Config::getParam('pools-cache', []);
$adapters = [];
foreach ($list as $value) {
$adapters[] = new CachePool($pools->get($value));
}
return new Cache(new Sharding($adapters));
}, ['register']);
Server::setResource('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
$pass = System::getEnv('_APP_REDIS_PASS', '');
$redis = new \Redis();
@$redis->pconnect($host, (int) $port);
if ($pass) {
$redis->auth($pass);
}
$redis->setOption(\Redis::OPT_READ_TIMEOUT, -1);
return $redis;
});
Server::setResource('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
};
}, ['redis']);
Server::setResource('log', fn () => new Log());
Server::setResource('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
Server::setResource('publisherDatabases', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherFunctions', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('consumer', function (Group $pools) {
$container->set('consumer', function (Group $pools) {
return new BrokerPool(consumer: $pools->get('consumer'));
}, ['pools']);
Server::setResource('consumerDatabases', function (BrokerPool $consumer) {
$container->set('consumerDatabases', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('consumerMigrations', function (BrokerPool $consumer) {
$container->set('consumerMigrations', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) {
$container->set('consumerStatsUsage', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('usage', function () {
return new Context();
}, []);
Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
Server::setResource('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($publisher);
}, ['publisher']);
Server::setResource('queueForMessaging', function (Publisher $publisher) {
return new Messaging($publisher);
}, ['publisher']);
Server::setResource('queueForMails', function (Publisher $publisher) {
return new Mail($publisher);
}, ['publisher']);
Server::setResource('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
Server::setResource('queueForScreenshots', function (Publisher $publisher) {
return new Screenshot($publisher);
}, ['publisher']);
Server::setResource('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
Server::setResource('queueForEvents', function (Publisher $publisher) {
return new Event($publisher);
}, ['publisher']);
Server::setResource('queueForAudits', function (Publisher $publisher) {
return new Audit($publisher);
}, ['publisher']);
Server::setResource('queueForWebhooks', function (Publisher $publisher) {
return new Webhook($publisher);
}, ['publisher']);
Server::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Server::setResource('queueForRealtime', function () {
return new Realtime();
}, []);
Server::setResource('queueForCertificates', function (Publisher $publisher) {
return new Certificate($publisher);
}, ['publisher']);
Server::setResource('queueForMigrations', function (Publisher $publisher) {
return new Migration($publisher);
}, ['publisher']);
Server::setResource('logger', function (Registry $register) {
return $register->get('logger');
}, ['register']);
Server::setResource('pools', function (Registry $register) {
return $register->get('pools');
}, ['register']);
Server::setResource('telemetry', fn () => new NoTelemetry());
Server::setResource('deviceForSites', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForFiles', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForCache', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
Server::setResource('plan', function (array $plan = []) {
return [];
});
Server::setResource('certificates', function () {
$container->set('certificates', function () {
$email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'));
if (empty($email)) {
throw new Exception('You must set a valid security email address (_APP_EMAIL_CERTIFICATES) to issue a LetsEncrypt SSL certificate.');
}
return new LetsEncrypt($email);
});
}, []);
Server::setResource('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']);
Server::setResource('executor', fn () => new Executor());
Server::setResource('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']);
Server::setResource('executionsRetentionCount', function (Document $project, array $plan) {
if ($project->getId() === 'console' || empty($plan)) {
return 0;
}
return (int) ($plan['executionsRetentionCount'] ?? 100);
}, ['project', 'plan']);
$pools = $register->get('pools');
$platform = new Appwrite();
$args = $platform->getEnv('argv');
$args = $_SERVER['argv'] ?? [];
if (! isset($args[1])) {
Console::error('Missing worker name');
@@ -575,38 +79,45 @@ if (\str_starts_with($workerName, 'databases')) {
$queueName = System::getEnv('_APP_QUEUE_NAME', 'v1-' . strtolower($workerName));
}
/** @var \Utopia\Pools\Group $pools */
$pools = $container->get('pools');
$adapter = new Swoole(
$pools->get('consumer')->pop()->getResource(),
System::getEnv('_APP_WORKERS_NUM', 1),
$queueName
);
$worker = new Server($adapter, $container);
try {
/**
* Any worker can be configured with the following env vars:
* - _APP_WORKERS_NUM The total number of worker processes
* - _APP_WORKER_PER_CORE The number of worker processes per core (ignored if _APP_WORKERS_NUM is set)
* - _APP_QUEUE_NAME The name of the queue to read for database events
*/
$worker->init()->action(function () use ($worker, $registerWorkerMessageResources) {
$registerWorkerMessageResources($worker->getContainer());
});
$container->set('bus', function ($register) use ($worker) {
return $register->get('bus')->setResolver(
fn (string $name) => $worker->getContainer()->get($name)
);
}, ['register']);
$platform->setWorker($worker);
$platform->init(Service::TYPE_WORKER, [
'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1),
'connection' => $pools->get('consumer')->pop()->getResource(),
'workerName' => strtolower($workerName) ?? null,
'queueName' => $queueName,
'workerName' => strtolower($workerName),
]);
} catch (\Throwable $e) {
Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
Console::exit(1);
}
$worker = $platform->getWorker();
Server::setResource('bus', function ($register) use ($worker) {
return $register->get('bus')->setResolver(fn (string $name) => $worker->getResource($name));
}, ['register']);
$worker
->error()
->inject('error')
->inject('logger')
->inject('log')
->inject('pools')
->inject('project')
->inject('authorization')
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($queueName) {
->action(function (Throwable $error, ?Logger $logger, Log $log, Document $project, Authorization $authorization) use ($queueName) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
if ($logger) {
+9 -10
View File
@@ -52,34 +52,34 @@
"appwrite/php-runtimes": "0.19.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/abuse": "1.2.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.2.*",
"utopia-php/auth": "0.5.*",
"utopia-php/cache": "1.0.*",
"utopia-php/cli": "0.22.*",
"utopia-php/cli": "0.23.*",
"utopia-php/compression": "0.1.*",
"utopia-php/config": "1.*",
"utopia-php/console": "0.1.*",
"utopia-php/database": "5.*",
"utopia-php/agents": "1.*",
"utopia-php/detector": "0.2.*",
"utopia-php/domains": "1.*",
"utopia-php/emails": "0.6.*",
"utopia-php/dns": "1.6.*",
"utopia-php/dsn": "0.2.1",
"utopia-php/framework": "0.33.*",
"utopia-php/framework": "0.34.*",
"utopia-php/fetch": "0.5.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.20.*",
"utopia-php/migration": "1.8.*",
"utopia-php/platform": "0.7.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.9.*",
"utopia-php/platform": "0.12.*",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
"utopia-php/queue": "0.15.*",
"utopia-php/servers": "0.2.5",
"utopia-php/queue": "0.17.*",
"utopia-php/servers": "0.3.*",
"utopia-php/registry": "0.5.*",
"utopia-php/storage": "1.0.*",
"utopia-php/system": "0.10.*",
@@ -94,8 +94,7 @@
"spomky-labs/otphp": "11.*",
"webonyx/graphql-php": "14.11.*",
"league/csv": "9.14.*",
"enshrined/svg-sanitize": "0.22.*",
"utopia-php/di": "0.1.0"
"enshrined/svg-sanitize": "0.22.*"
},
"require-dev": {
"ext-fileinfo": "*",
Generated
+182 -119
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f9225f2b580de0ccb796b2fb8c881384",
"content-hash": "e9c38bbebc60849e70e3640aaa4422cd",
"packages": [
{
"name": "adhocore/jwt",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
"version": "0.19.4",
"version": "0.19.5",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
"reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b"
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/eea9d1b3ca2540eab623b419c8afde09ef406c0b",
"reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546",
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
"source": "https://github.com/appwrite/runtimes/tree/0.19.4"
"source": "https://github.com/appwrite/runtimes/tree/0.19.5"
},
"time": "2026-02-17T10:04:39+00:00"
"time": "2026-04-01T01:39:23+00:00"
},
{
"name": "brick/math",
@@ -1226,16 +1226,16 @@
},
{
"name": "open-telemetry/api",
"version": "1.8.0",
"version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/api.git",
"reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad"
"reference": "6f8d237ce2c304ca85f31970f788e7f074d147be"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad",
"reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad",
"url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be",
"reference": "6f8d237ce2c304ca85f31970f788e7f074d147be",
"shasum": ""
},
"require": {
@@ -1292,20 +1292,20 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
"time": "2026-01-21T04:14:03+00:00"
"time": "2026-02-25T13:24:05+00:00"
},
{
"name": "open-telemetry/context",
"version": "1.4.0",
"version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/context.git",
"reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf"
"reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf",
"reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf",
"url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07",
"reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07",
"shasum": ""
},
"require": {
@@ -1347,11 +1347,11 @@
],
"support": {
"chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
"docs": "https://opentelemetry.io/docs/php",
"docs": "https://opentelemetry.io/docs/languages/php",
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
"time": "2025-09-19T00:05:49+00:00"
"time": "2025-10-19T06:44:33+00:00"
},
{
"name": "open-telemetry/exporter-otlp",
@@ -1419,16 +1419,16 @@
},
{
"name": "open-telemetry/gen-otlp-protobuf",
"version": "1.8.0",
"version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git",
"reference": "673af5b06545b513466081884b47ef15a536edde"
"reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde",
"reference": "673af5b06545b513466081884b47ef15a536edde",
"url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9",
"reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9",
"shasum": ""
},
"require": {
@@ -1474,30 +1474,30 @@
],
"support": {
"chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
"docs": "https://opentelemetry.io/docs/php",
"docs": "https://opentelemetry.io/docs/languages/php",
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
"time": "2025-09-17T23:10:12+00:00"
"time": "2025-10-19T06:44:33+00:00"
},
{
"name": "open-telemetry/sdk",
"version": "1.13.0",
"version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/sdk.git",
"reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1"
"reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/c76f91203bf7ef98ab3f4e0a82ca21699af185e1",
"reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1",
"url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
"reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
"shasum": ""
},
"require": {
"ext-json": "*",
"nyholm/psr7-server": "^1.1",
"open-telemetry/api": "^1.7",
"open-telemetry/api": "^1.8",
"open-telemetry/context": "^1.4",
"open-telemetry/sem-conv": "^1.0",
"php": "^8.1",
@@ -1575,7 +1575,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
"time": "2026-01-28T11:38:11+00:00"
"time": "2026-03-21T11:50:01+00:00"
},
{
"name": "open-telemetry/sem-conv",
@@ -3658,21 +3658,21 @@
},
{
"name": "utopia-php/cli",
"version": "0.22.0",
"version": "0.23.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cli.git",
"reference": "a7ac387ee626fd27075a87e836fb72c5be38add4"
"reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/cli/zipball/a7ac387ee626fd27075a87e836fb72c5be38add4",
"reference": "a7ac387ee626fd27075a87e836fb72c5be38add4",
"url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
"reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
"shasum": ""
},
"require": {
"php": ">=7.4",
"utopia-php/servers": "0.2.*"
"utopia-php/servers": "0.3.*"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -3703,9 +3703,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/cli/issues",
"source": "https://github.com/utopia-php/cli/tree/0.22.0"
"source": "https://github.com/utopia-php/cli/tree/0.23.1"
},
"time": "2025-10-21T10:42:45+00:00"
"time": "2026-04-05T15:27:35+00:00"
},
{
"name": "utopia-php/compression",
@@ -3954,25 +3954,26 @@
},
{
"name": "utopia-php/di",
"version": "0.1.0",
"version": "0.3.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/di.git",
"reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31"
"reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31",
"reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31",
"url": "https://api.github.com/repos/utopia-php/di/zipball/07025d721ed5d9be27932e8e640acf1467fc4b9d",
"reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d",
"shasum": ""
},
"require": {
"php": ">=8.2"
"php": ">=8.2",
"psr/container": "^2.0"
},
"require-dev": {
"laravel/pint": "^1.2",
"laravel/pint": "^1.27",
"phpbench/phpbench": "^1.2",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.5.25",
"swoole/ide-helper": "4.8.3"
},
@@ -3989,29 +3990,31 @@
],
"description": "A simple and lite library for managing dependency injections",
"keywords": [
"framework",
"http",
"PSR-11",
"container",
"dependency-injection",
"di",
"php",
"upf"
"utopia"
],
"support": {
"issues": "https://github.com/utopia-php/di/issues",
"source": "https://github.com/utopia-php/di/tree/0.1.0"
"source": "https://github.com/utopia-php/di/tree/0.3.2"
},
"time": "2024-08-08T14:35:19+00:00"
"time": "2026-03-21T07:42:10+00:00"
},
{
"name": "utopia-php/dns",
"version": "1.6.5",
"version": "1.6.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/dns.git",
"reference": "574327f0f5fabefa7048030c5634cde33ad10640"
"reference": "917901ecfe5f09a540e4f689b6cbb80b9f55035d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/dns/zipball/574327f0f5fabefa7048030c5634cde33ad10640",
"reference": "574327f0f5fabefa7048030c5634cde33ad10640",
"url": "https://api.github.com/repos/utopia-php/dns/zipball/917901ecfe5f09a540e4f689b6cbb80b9f55035d",
"reference": "917901ecfe5f09a540e4f689b6cbb80b9f55035d",
"shasum": ""
},
"require": {
@@ -4053,9 +4056,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/dns/issues",
"source": "https://github.com/utopia-php/dns/tree/1.6.5"
"source": "https://github.com/utopia-php/dns/tree/1.6.6"
},
"time": "2026-02-19T16:06:46+00:00"
"time": "2026-03-27T11:13:50+00:00"
},
{
"name": "utopia-php/domains",
@@ -4268,30 +4271,34 @@
},
{
"name": "utopia-php/framework",
"version": "0.33.41",
"version": "0.34.18",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
"reference": "0f3bf2377c867e547c929c3733b8224afee6ef06"
"reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/http/zipball/0f3bf2377c867e547c929c3733b8224afee6ef06",
"reference": "0f3bf2377c867e547c929c3733b8224afee6ef06",
"url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc",
"reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc",
"shasum": ""
},
"require": {
"php": ">=8.3",
"ext-swoole": "*",
"php": ">=8.2",
"utopia-php/compression": "0.1.*",
"utopia-php/di": "0.3.*",
"utopia-php/servers": "0.3.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
"doctrine/instantiator": "^1.5",
"laravel/pint": "1.*",
"phpbench/phpbench": "1.*",
"phpbench/phpbench": "^1.2",
"phpstan/phpstan": "1.*",
"phpunit/phpunit": "9.*",
"swoole/ide-helper": "^6.0"
"phpunit/phpunit": "^9.5.25",
"swoole/ide-helper": "4.8.3"
},
"type": "library",
"autoload": {
@@ -4303,17 +4310,72 @@
"license": [
"MIT"
],
"description": "A simple, light and advanced PHP framework",
"description": "A simple, light and advanced PHP HTTP framework",
"keywords": [
"framework",
"http",
"php",
"upf"
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
"source": "https://github.com/utopia-php/http/tree/0.33.41"
"source": "https://github.com/utopia-php/http/tree/0.34.18"
},
"time": "2026-02-24T12:01:28+00:00"
"time": "2026-04-07T08:06:39+00:00"
},
{
"name": "utopia-php/http",
"version": "0.34.16",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
"reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/http/zipball/2b4021ba3f9d476264ce9fd6703d6c79de9add7f",
"reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f",
"shasum": ""
},
"require": {
"ext-swoole": "*",
"php": ">=8.2",
"utopia-php/compression": "0.1.*",
"utopia-php/di": "0.3.*",
"utopia-php/servers": "0.3.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
"doctrine/instantiator": "^1.5",
"laravel/pint": "1.*",
"phpbench/phpbench": "^1.2",
"phpstan/phpstan": "1.*",
"phpunit/phpunit": "^9.5.25",
"swoole/ide-helper": "4.8.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Utopia\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A simple, light and advanced PHP HTTP framework",
"keywords": [
"framework",
"http",
"php",
"upf"
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
"source": "https://github.com/utopia-php/http/tree/0.34.16"
},
"time": "2026-03-20T10:39:07+00:00"
},
{
"name": "utopia-php/image",
@@ -4467,23 +4529,23 @@
},
{
"name": "utopia-php/messaging",
"version": "0.20.1",
"version": "0.22.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/messaging.git",
"reference": "fcb4c3c46a48008a677957690bd45ec934dd33b0"
"reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/messaging/zipball/fcb4c3c46a48008a677957690bd45ec934dd33b0",
"reference": "fcb4c3c46a48008a677957690bd45ec934dd33b0",
"url": "https://api.github.com/repos/utopia-php/messaging/zipball/a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030",
"reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-openssl": "*",
"giggsey/libphonenumber-for-php-lite": "9.0.23",
"php": ">=8.0.0",
"php": ">=8.1.0",
"phpmailer/phpmailer": "6.9.1"
},
"require-dev": {
@@ -4512,22 +4574,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/messaging/issues",
"source": "https://github.com/utopia-php/messaging/tree/0.20.1"
"source": "https://github.com/utopia-php/messaging/tree/0.22.0"
},
"time": "2026-02-06T09:56:06+00:00"
"time": "2026-04-02T04:09:19+00:00"
},
{
"name": "utopia-php/migration",
"version": "1.8.3",
"version": "1.9.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "8633523b3343d492427331b6eec53f020f6ab7a7"
"reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/8633523b3343d492427331b6eec53f020f6ab7a7",
"reference": "8633523b3343d492427331b6eec53f020f6ab7a7",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
"reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
"shasum": ""
},
"require": {
@@ -4567,9 +4629,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.8.3"
"source": "https://github.com/utopia-php/migration/tree/1.9.1"
},
"time": "2026-03-19T09:18:47+00:00"
"time": "2026-03-25T07:05:27+00:00"
},
{
"name": "utopia-php/mongo",
@@ -4634,30 +4696,30 @@
},
{
"name": "utopia-php/platform",
"version": "0.7.16",
"version": "0.12.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/platform.git",
"reference": "34e67e4b80b5741c380071fe765fbc12a132de4f"
"reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/platform/zipball/34e67e4b80b5741c380071fe765fbc12a132de4f",
"reference": "34e67e4b80b5741c380071fe765fbc12a132de4f",
"url": "https://api.github.com/repos/utopia-php/platform/zipball/068ee46228f0c3972e6b569f2c86b6c80fe583d8",
"reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-redis": "*",
"php": ">=8.0",
"utopia-php/cli": "0.22.*",
"utopia-php/framework": "0.33.*",
"utopia-php/queue": "0.15.*"
"php": ">=8.1",
"utopia-php/cli": "0.23.*",
"utopia-php/http": "0.34.*",
"utopia-php/queue": "0.17.*",
"utopia-php/servers": "0.3.*"
},
"require-dev": {
"laravel/pint": "1.*",
"phpstan/phpstan": "2.*",
"phpunit/phpunit": "9.*"
"laravel/pint": "1.2.*",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
@@ -4679,9 +4741,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/platform/issues",
"source": "https://github.com/utopia-php/platform/tree/0.7.16"
"source": "https://github.com/utopia-php/platform/tree/0.12.0"
},
"time": "2026-02-11T06:36:48+00:00"
"time": "2026-03-31T14:44:23+00:00"
},
{
"name": "utopia-php/pools",
@@ -4791,32 +4853,33 @@
},
{
"name": "utopia-php/queue",
"version": "0.15.6",
"version": "0.17.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/queue.git",
"reference": "08e361d69610f371382b344c369eef355ca414b4"
"reference": "0fbc7d7312f5cf76ec112513fb93317000901f5f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/queue/zipball/08e361d69610f371382b344c369eef355ca414b4",
"reference": "08e361d69610f371382b344c369eef355ca414b4",
"url": "https://api.github.com/repos/utopia-php/queue/zipball/0fbc7d7312f5cf76ec112513fb93317000901f5f",
"reference": "0fbc7d7312f5cf76ec112513fb93317000901f5f",
"shasum": ""
},
"require": {
"php": ">=8.3",
"php-amqplib/php-amqplib": "^3.7",
"utopia-php/di": "0.3.*",
"utopia-php/fetch": "0.5.*",
"utopia-php/pools": "1.*",
"utopia-php/servers": "0.2.*",
"utopia-php/servers": "0.3.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
"ext-redis": "*",
"laravel/pint": "^0.2.3",
"laravel/pint": "^1.0",
"phpstan/phpstan": "^1.8",
"phpunit/phpunit": "^9.5.5",
"phpunit/phpunit": "^11.0",
"swoole/ide-helper": "4.8.8",
"workerman/workerman": "^4.0"
},
@@ -4851,9 +4914,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/queue/issues",
"source": "https://github.com/utopia-php/queue/tree/0.15.6"
"source": "https://github.com/utopia-php/queue/tree/0.17.0"
},
"time": "2026-02-23T13:03:51+00:00"
"time": "2026-03-23T16:21:31+00:00"
},
{
"name": "utopia-php/registry",
@@ -4909,21 +4972,21 @@
},
{
"name": "utopia-php/servers",
"version": "0.2.5",
"version": "0.3.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/servers.git",
"reference": "4770e879a90685af4ba14e7e5d95d0a17c7fdf03"
"reference": "235be31200df9437fc96a1c270ffef4c64fafe52"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/servers/zipball/4770e879a90685af4ba14e7e5d95d0a17c7fdf03",
"reference": "4770e879a90685af4ba14e7e5d95d0a17c7fdf03",
"url": "https://api.github.com/repos/utopia-php/servers/zipball/235be31200df9437fc96a1c270ffef4c64fafe52",
"reference": "235be31200df9437fc96a1c270ffef4c64fafe52",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/di": "0.1.*",
"php": ">=8.2",
"utopia-php/di": "0.3.*",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4957,9 +5020,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/servers/issues",
"source": "https://github.com/utopia-php/servers/tree/0.2.5"
"source": "https://github.com/utopia-php/servers/tree/0.3.0"
},
"time": "2026-02-10T04:21:53+00:00"
"time": "2026-03-13T11:31:42+00:00"
},
{
"name": "utopia-php/span",
@@ -5439,16 +5502,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "1.12.1",
"version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "a724aa8db52f83ea35854a004837fa5ce990b736"
"reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a724aa8db52f83ea35854a004837fa5ce990b736",
"reference": "a724aa8db52f83ea35854a004837fa5ce990b736",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e",
"reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e",
"shasum": ""
},
"require": {
@@ -5484,9 +5547,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/1.12.1"
"source": "https://github.com/appwrite/sdk-generator/tree/1.14.0"
},
"time": "2026-03-24T05:18:43+00:00"
"time": "2026-03-26T12:50:11+00:00"
},
{
"name": "brianium/paratest",
@@ -6195,11 +6258,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.42",
"version": "2.1.44",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0",
"reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218",
"reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218",
"shasum": ""
},
"require": {
@@ -6244,7 +6307,7 @@
"type": "github"
}
],
"time": "2026-03-17T14:58:32+00:00"
"time": "2026-03-25T17:34:21+00:00"
},
{
"name": "phpunit/php-code-coverage",
+1 -1
View File
@@ -1 +1 @@
Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server.
Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server.
@@ -0,0 +1 @@
Export documents to a JSON file from your Appwrite database. This endpoint allows you to export documents to a JSON file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete.
@@ -0,0 +1 @@
Import documents from a JSON file into your Appwrite database. This endpoint allows you to import documents from a JSON file uploaded to Appwrite Storage bucket.
-17
View File
@@ -1,17 +0,0 @@
# Change Log
## 0.3.0
* Add `bytesMax` and `bytesUsed` properties to Collection and Table documentation
* Add `queries` parameter to `listKeys` and `keyId` parameter to `createKey` documentation
* Add `dart-3.10` and `flutter-3.38` runtimes
* Fix Teams membership docs to use `string[]` instead of `Roles[]`
## 0.2.0
* Document array-based enum parameters in Markdown examples (e.g., `permissions: BrowserPermission[]`).
* Breaking change: `Output` enum has been removed; use `ImageFormat` instead.
## 0.1.0
* Initial release
+5
View File
@@ -0,0 +1,5 @@
# Change Log
## 0.1.0
* Initial release
+68
View File
@@ -0,0 +1,68 @@
## Getting Started
### Init your SDK
Initialize your SDK with your Appwrite server API endpoint and project ID which can be found on your project settings page and your new API secret Key from project's API keys section.
```rust
use appwrite::client::Client;
let client = Client::new()
.set_endpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint
.set_project("5df5acd0d48c2") // Your project ID
.set_key("919c2d18fb5d4...a2ae413da83346ad2") // Your secret API key
.set_self_signed(true); // Use only on dev mode with a self-signed SSL cert
```
### Make Your First Request
Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section.
```rust
use appwrite::client::Client;
use appwrite::services::users::Users;
use appwrite::id::ID;
let client = Client::new()
.set_endpoint("https://[HOSTNAME_OR_IP]/v1")
.set_project("5df5acd0d48c2")
.set_key("919c2d18fb5d4...a2ae413da83346ad2")
.set_self_signed(true);
let users = Users::new(&client);
let user = users.create(
ID::unique(),
Some("email@example.com"),
Some("+123456789"),
Some("password"),
Some("Walter O'Brien"),
).await?;
println!("{}", user.name);
println!("{}", user.email);
```
### Error Handling
The Appwrite Rust SDK returns `Result` types. You can handle errors using standard Rust error handling patterns. Below is an example.
```rust
use appwrite::error::AppwriteError;
match users.create(
ID::unique(),
Some("email@example.com"),
Some("+123456789"),
Some("password"),
Some("Walter O'Brien"),
).await {
Ok(user) => println!("{}", user.name),
Err(AppwriteError { message, code, .. }) => {
eprintln!("Error {}: {}", code, message);
}
}
```
### Learn more
You can use the following resources to learn more and get help
- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-server)
- 📜 [Appwrite Docs](https://appwrite.io/docs)
- 💬 [Discord Community](https://appwrite.io/discord)
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -1,8 +1,6 @@
includes:
- phpstan-baseline.neon
parameters:
level: 3
tmpDir: .phpstan-cache
paths:
- src
- app
@@ -14,4 +12,3 @@ parameters:
- vendor/swoole/ide-helper
excludePaths:
- tests/resources
+1 -1
View File
@@ -155,7 +155,7 @@ abstract class OAuth2
/**
* @param string $code
*
* @return string
* @return int
*/
public function getAccessTokenExpiry(string $code): int
{
+1 -1
View File
@@ -108,7 +108,7 @@ class Disqus extends OAuth2
}
/**
* @param string $token
* @param string $accessToken
*
* @return string
*/
+3 -2
View File
@@ -23,8 +23,9 @@ class Yahoo extends OAuth2
* @var array
*/
protected array $scopes = [
'sdct-r',
'sdpp-w',
'openid',
'profile',
'email',
];
/**
+1 -1
View File
@@ -33,7 +33,7 @@ class PersonalData extends Password
/**
* Is valid.
*
* @param mixed $value
* @param mixed $password
*
* @return bool
*/
-6
View File
@@ -6,14 +6,8 @@ use DeviceDetector\DeviceDetector;
class Detector
{
/**
* @param string
*/
protected $userAgent = '';
/**
* @param DeviceDetector
*/
protected $detctor;
/**
-3
View File
@@ -12,9 +12,6 @@ class Compose
*/
protected $compose = [];
/**
* @var string $data
*/
public function __construct(string $data)
{
$this->compose = yaml_parse($data);
-3
View File
@@ -11,9 +11,6 @@ class Service
*/
protected $service = [];
/**
* @var string $path
*/
public function __construct(array $service)
{
$this->service = $service;
-3
View File
@@ -9,9 +9,6 @@ class Env
*/
protected $vars = [];
/**
* @var string $data
*/
public function __construct(string $data)
{
$data = explode("\n", $data);
+3 -5
View File
@@ -285,7 +285,7 @@ class Event
*
* @param string $key
* @param Document $context
* @return self
* @return static
*/
public function setContext(string $key, Document $context): self
{
@@ -309,7 +309,7 @@ class Event
/**
* Set class used for this event.
* @param string $class
* @return self
* @return static
*/
public function setClass(string $class): self
{
@@ -648,10 +648,8 @@ class Event
*
* @param Event $event
*
* @return self
*
*/
public function from(Event $event): self
public function from(Event $event): static
{
$this->project = $event->getProject();
$this->user = $event->getUser();
+5 -4
View File
@@ -101,7 +101,8 @@ class Mail extends Event
/**
* Sets preview for the mail event.
*
* @return string
* @param string $preview
* @return self
*/
public function setPreview(string $preview): self
{
@@ -115,7 +116,7 @@ class Mail extends Event
*
* @return string
*/
public function getPreview(string $preview): string
public function getPreview(): string
{
return $this->preview;
}
@@ -181,7 +182,7 @@ class Mail extends Event
/**
* Set SMTP port
*
* @param int port
* @param int $port
* @return self
*/
public function setSmtpPort(int $port): self
@@ -217,7 +218,7 @@ class Mail extends Event
/**
* Set SMTP secure
*
* @param string $password
* @param string $secure
* @return self
*/
public function setSmtpSecure(string $secure): self
+2 -1
View File
@@ -40,7 +40,8 @@ class Usage extends Base
*/
public static function fromArray(array $data): static
{
return new self(
/** @phpstan-ignore new.static (subclass constructors are backwards-compatible via optional params) */
return new static(
project: new Document($data['project'] ?? []),
metrics: $data['metrics'] ?? [],
reduce: array_map(fn (array $doc) => new Document($doc), $data['reduce'] ?? []),
+2 -2
View File
@@ -86,7 +86,7 @@ class Messaging extends Event
/**
* Returns message document for the messaging event.
*
* @return string
* @return Document
*/
public function getMessage(): Document
{
@@ -96,7 +96,7 @@ class Messaging extends Event
/**
* Sets message ID for the messaging event.
*
* @param string $message
* @param string $messageId
* @return self
*/
public function setMessageId(string $messageId): self
+3
View File
@@ -82,6 +82,9 @@ class Exception extends \Exception
public const string USER_PASSWORD_RECENTLY_USED = 'password_recently_used';
public const string USER_PASSWORD_PERSONAL_DATA = 'password_personal_data';
public const string USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
public const string USER_EMAIL_DISPOSABLE = 'user_email_disposable';
public const string USER_EMAIL_FREE = 'user_email_free';
public const string USER_EMAIL_NOT_CANONICAL = 'user_email_not_canonical';
public const string USER_PASSWORD_MISMATCH = 'user_password_mismatch';
public const string USER_SESSION_NOT_FOUND = 'user_session_not_found';
public const string USER_IDENTITY_NOT_FOUND = 'user_identity_not_found';
+18 -4
View File
@@ -8,6 +8,18 @@ use Utopia\Database\Query;
class EventProcessor
{
/**
* @param array<mixed> $events
* @return array<string, bool>
*/
private function getEventMap(array $events): array
{
return \array_fill_keys(
\array_map('strval', \array_unique($events)),
true
);
}
/**
* Get function events for a project, using Redis cache
* @param Document|null $project
@@ -26,7 +38,7 @@ class EventProcessor
$cacheKey = \sprintf(
'%s-cache-%s:%s:%s:project:%s:functions:events',
$dbForProject->getCacheName(),
$hostname ?? '',
$hostname,
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$project->getId()
@@ -36,7 +48,9 @@ class EventProcessor
$cachedFunctionEvents = $dbForProject->getCache()->load($cacheKey, $ttl);
if ($cachedFunctionEvents !== false) {
return \json_decode($cachedFunctionEvents, true) ?? [];
$decoded = \json_decode($cachedFunctionEvents, true);
return \is_array($decoded) ? $this->getEventMap(\array_keys($decoded)) : [];
}
$events = [];
@@ -63,7 +77,7 @@ class EventProcessor
}
}
$uniqueEvents = \array_flip(\array_unique($events));
$uniqueEvents = $this->getEventMap($events);
$dbForProject->getCache()->save($cacheKey, \json_encode($uniqueEvents));
return $uniqueEvents;
@@ -97,6 +111,6 @@ class EventProcessor
}
}
return \array_flip(\array_unique($events));
return $this->getEventMap($events);
}
}
+25 -29
View File
@@ -25,14 +25,10 @@ class Resolvers
?Route $route,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $route, $args, $context, $info) {
/** @var Http $utopia */
/** @var Response $response */
/** @var Request $request */
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
function (callable $resolve, callable $reject) use ($utopia, $route, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$path = $route->getPath();
foreach ($args as $key => $value) {
@@ -96,10 +92,10 @@ class Resolvers
callable $url,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $type, $args) {
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$request->setMethod('GET');
$request->setURI($url($databaseId, $collectionId, $args));
@@ -127,10 +123,10 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$request->setMethod('GET');
$request->setURI($url($databaseId, $collectionId, $args));
@@ -163,10 +159,10 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$request->setMethod('POST');
$request->setURI($url($databaseId, $collectionId, $args));
@@ -195,10 +191,10 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$request->setMethod('PATCH');
$request->setURI($url($databaseId, $collectionId, $args));
@@ -225,10 +221,10 @@ class Resolvers
callable $url,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $type, $args) {
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$request->setMethod('DELETE');
$request->setURI($url($databaseId, $collectionId, $args));
@@ -270,7 +266,7 @@ class Resolvers
try {
$route = $utopia->match($request, fresh: true);
$utopia->execute($route, $request, $response);
$utopia->execute($route, $request);
} catch (\Throwable $e) {
if ($beforeReject) {
$e = $beforeReject($e);
+76 -79
View File
@@ -32,10 +32,6 @@ class Schema
array $urls,
array $params,
): GQLSchema {
Http::setResource('utopia:graphql', static function () use ($utopia) {
return $utopia;
});
if (!empty(self::$schema)) {
return self::$schema;
}
@@ -98,10 +94,9 @@ class Schema
foreach ($routes as $route) {
/** @var Route $route */
/** @var \Appwrite\SDK\Method $sdk */
$sdk = $route->getLabel('sdk', false);
if (empty($sdk)) {
if ($sdk === false) {
continue;
}
@@ -177,7 +172,7 @@ class Schema
$required = $attr['required'];
$default = $attr['default'];
$escapedKey = str_replace('$', '', $key);
$collections[$collectionId][$escapedKey] = [
$collections[$databaseId][$collectionId][$escapedKey] = [
'type' => Mapper::attribute(
$type,
$array,
@@ -187,80 +182,82 @@ class Schema
];
}
foreach ($collections as $collectionId => $attributes) {
$objectType = new ObjectType([
'name' => $collectionId,
'fields' => \array_merge(
["_id" => ['type' => Type::string()]],
$attributes
),
]);
$attributes = \array_merge(
$attributes,
Mapper::args('mutate')
);
$queryFields[$collectionId . 'Get'] = [
'type' => $objectType,
'args' => Mapper::args('id'),
'resolve' => Resolvers::documentGet(
$utopia,
$databaseId,
$collectionId,
$urls['get'],
)
];
$queryFields[$collectionId . 'List'] = [
'type' => Type::listOf($objectType),
'args' => Mapper::args('list'),
'resolve' => Resolvers::documentList(
$utopia,
$databaseId,
$collectionId,
$urls['list'],
$params['list'],
),
'complexity' => $complexity,
];
$mutationFields[$collectionId . 'Create'] = [
'type' => $objectType,
'args' => $attributes,
'resolve' => Resolvers::documentCreate(
$utopia,
$databaseId,
$collectionId,
$urls['create'],
$params['create'],
)
];
$mutationFields[$collectionId . 'Update'] = [
'type' => $objectType,
'args' => \array_merge(
Mapper::args('id'),
\array_map(
fn ($attr) => $attr['type'] = Type::getNullableType($attr['type']),
foreach ($collections as $databaseId => $databaseCollections) {
foreach ($databaseCollections as $collectionId => $attributes) {
$objectType = new ObjectType([
'name' => $collectionId,
'fields' => \array_merge(
["_id" => ['type' => Type::string()]],
$attributes
),
]);
$attributes = \array_merge(
$attributes,
Mapper::args('mutate')
);
$queryFields[$collectionId . 'Get'] = [
'type' => $objectType,
'args' => Mapper::args('id'),
'resolve' => Resolvers::documentGet(
$utopia,
$databaseId,
$collectionId,
$urls['get'],
)
),
'resolve' => Resolvers::documentUpdate(
$utopia,
$databaseId,
$collectionId,
$urls['update'],
$params['update'],
)
];
$mutationFields[$collectionId . 'Delete'] = [
'type' => Mapper::model('none'),
'args' => Mapper::args('id'),
'resolve' => Resolvers::documentDelete(
$utopia,
$databaseId,
$collectionId,
$urls['delete'],
)
];
];
$queryFields[$collectionId . 'List'] = [
'type' => Type::listOf($objectType),
'args' => Mapper::args('list'),
'resolve' => Resolvers::documentList(
$utopia,
$databaseId,
$collectionId,
$urls['list'],
$params['list'],
),
'complexity' => $complexity,
];
$mutationFields[$collectionId . 'Create'] = [
'type' => $objectType,
'args' => $attributes,
'resolve' => Resolvers::documentCreate(
$utopia,
$databaseId,
$collectionId,
$urls['create'],
$params['create'],
)
];
$mutationFields[$collectionId . 'Update'] = [
'type' => $objectType,
'args' => \array_merge(
Mapper::args('id'),
\array_map(
fn ($attr) => $attr['type'] = Type::getNullableType($attr['type']),
$attributes
)
),
'resolve' => Resolvers::documentUpdate(
$utopia,
$databaseId,
$collectionId,
$urls['update'],
$params['update'],
)
];
$mutationFields[$collectionId . 'Delete'] = [
'type' => Mapper::model('none'),
'args' => Mapper::args('id'),
'resolve' => Resolvers::documentDelete(
$utopia,
$databaseId,
$collectionId,
$urls['delete'],
)
];
}
}
$offset += $limit;
}
+16 -7
View File
@@ -15,10 +15,13 @@ class Types
*
* @return Json
*/
public static function json(): Type
public static function json(): Json
{
if (Registry::has(Json::class)) {
return Registry::get(Json::class);
$type = Registry::get(Json::class);
if ($type instanceof Json) {
return $type;
}
}
$type = new Json();
Registry::set(Json::class, $type);
@@ -28,12 +31,15 @@ class Types
/**
* Get the JSON type.
*
* @return Json
* @return Assoc
*/
public static function assoc(): Type
public static function assoc(): Assoc
{
if (Registry::has(Assoc::class)) {
return Registry::get(Assoc::class);
$type = Registry::get(Assoc::class);
if ($type instanceof Assoc) {
return $type;
}
}
$type = new Assoc();
Registry::set(Assoc::class, $type);
@@ -45,10 +51,13 @@ class Types
*
* @return InputFile
*/
public static function inputFile(): Type
public static function inputFile(): InputFile
{
if (Registry::has(InputFile::class)) {
return Registry::get(InputFile::class);
$type = Registry::get(InputFile::class);
if ($type instanceof InputFile) {
return $type;
}
}
$type = new InputFile();
Registry::set(InputFile::class, $type);
+8 -10
View File
@@ -101,16 +101,16 @@ class Mapper
if (\is_array($modelName)) {
foreach ($modelName as $name) {
$models[] = static::$models[$name];
$models[] = self::$models[$name];
}
} else {
$models[] = static::$models[$modelName];
$models[] = self::$models[$modelName];
}
}
} else {
// If single response, get its model and wrap in array
$modelName = $responses->getModel();
$models = [static::$models[$modelName]];
$models = [self::$models[$modelName]];
}
foreach ($models as $model) {
@@ -273,11 +273,9 @@ class Mapper
case \Appwrite\Auth\Validator\Password::class:
case \Appwrite\Event\Validator\Event::class:
case \Appwrite\Event\Validator\FunctionEvent::class:
case \Appwrite\Network\Validator\CNAME::class:
case \Utopia\Emails\Validator\Email::class:
case \Appwrite\Network\Validator\Redirect::class:
case \Appwrite\Network\Validator\DNS::class:
case \Appwrite\Network\Validator\Origin::class:
case \Appwrite\Task\Validator\Cron::class:
case \Appwrite\Utopia\Database\Validator\CustomId::class:
case \Utopia\Database\Validator\Key::class:
@@ -286,7 +284,7 @@ class Mapper
case \Utopia\Validator\HexColor::class:
case \Utopia\Validator\Host::class:
case \Utopia\Validator\IP::class:
case \Utopia\Validator\Origin::class:
case \Appwrite\Network\Validator\Origin::class:
case \Utopia\Validator\Text::class:
case \Utopia\Validator\URL::class:
case \Utopia\Validator\WhiteList::class:
@@ -425,7 +423,7 @@ class Mapper
'name' => $unionName,
'types' => $types,
'resolveType' => static function ($object) use ($unionName) {
return static::getUnionImplementation($unionName, $object);
return self::getUnionImplementation($unionName, $object);
},
]);
@@ -440,11 +438,11 @@ class Mapper
switch ($name) {
case 'Attributes':
return static::getColumnImplementation($object);
return self::getColumnImplementation($object);
case 'Columns':
return static::getColumnImplementation($object, true);
return self::getColumnImplementation($object, true);
case 'HashOptions':
return static::getHashOptionsImplementation($object);
return self::getHashOptionsImplementation($object);
}
throw new Exception('Unknown union type: ' . $name);
+25
View File
@@ -13,6 +13,7 @@ use Utopia\Database\Exception\Limit;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Helpers\ID;
use Utopia\Database\PDO;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
abstract class Migration
@@ -204,6 +205,30 @@ abstract class Migration
}
}
/**
* @param array<Query> $queries
* @return \Generator<int, Document>
* @throws Exception
*/
protected function documentsIterator(string $collection, array $queries = []): \Generator
{
$offset = 0;
do {
$documents = $this->dbForProject->find($collection, [
...$queries,
Query::limit($this->limit),
Query::offset($offset),
]);
foreach ($documents as $document) {
yield $document;
}
$offset += \count($documents);
} while (\count($documents) === $this->limit);
}
/**
* Creates collection from the config collection.
*
+4 -5
View File
@@ -1224,7 +1224,7 @@ class V15 extends Migration
* @param \Utopia\Database\Document $document
* @return \Utopia\Database\Document
*/
protected function fixDocument(Document $document)
protected function fixDocument(Document $document): Document
{
switch ($document->getCollection()) {
case 'cache':
@@ -1234,7 +1234,7 @@ class V15 extends Migration
* skipping migration for 'cache' and 'variables'.
* 'users' already migrated.
*/
return;
return $document;
case '_metadata':
/**
@@ -1480,7 +1480,6 @@ class V15 extends Migration
* Filter from the 'encrypt' filter.
*
* @param string $value
* @return string|false
*/
protected function encryptFilter(string $value): string
{
@@ -1492,8 +1491,8 @@ class V15 extends Migration
'data' => OpenSSL::encrypt($value, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag),
'method' => OpenSSL::CIPHER_AES_128_GCM,
'iv' => \bin2hex($iv),
'tag' => \bin2hex($tag ?? ''),
'tag' => \bin2hex($tag),
'version' => '1',
]);
]) ?: '';
}
}
+1 -1
View File
@@ -452,7 +452,7 @@ class V20 extends Migration
Query::equal('period', ['1d']),
]);
$value = $query ?? 0;
$value = $query;
$this->createInfMetric($to, $value);
}
+14
View File
@@ -187,6 +187,20 @@ class V24 extends Migration
$this->dbForProject->purgeCachedCollection($id);
break;
case 'users':
try {
$this->createAttributeFromCollection($this->dbForProject, $id, 'impersonator');
} catch (Throwable $th) {
Console::warning("Failed to create attribute \"impersonator\" in collection {$id}: {$th->getMessage()}");
}
try {
$this->createIndexFromCollection($this->dbForProject, $id, 'impersonator');
} catch (Throwable $th) {
Console::warning("Failed to create index \"impersonator\" from {$id}: {$th->getMessage()}");
}
$this->dbForProject->purgeCachedCollection($id);
break;
case 'teams':
try {
$this->createAttributeFromCollection($this->dbForProject, $id, 'labels');
+1 -1
View File
@@ -48,7 +48,7 @@ final class Cors
/**
* Build CORS headers for a given request origin.
*
* @return array<string,string>
* @return array<string, int|string>
*/
public function headers(string $origin): array
{
+1 -1
View File
@@ -18,7 +18,7 @@ class OpenSSL
*
* @return string
*/
public static function encrypt($data, $method, $key, $options = 0, $iv = '', &$tag = null, $aad = '', $tag_length = 16)
public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16)
{
return \openssl_encrypt($data, $method, $key, $options, $iv, $tag, $aad, $tag_length);
}
+1 -1
View File
@@ -37,7 +37,7 @@ class Action extends UtopiaAction
* Foreach Document
* Call provided callback for each document in the collection
*
* @param string $projectId
* @param Database $database
* @param string $collection
* @param array $queries
* @param callable $callback
@@ -43,6 +43,7 @@ class Install extends Action
->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true)
->param('installId', '', new Text(64, 0), 'Installation ID', true)
->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true)
->param('migrate', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', true)
->inject('request')
->inject('response')
->inject('swooleResponse')
@@ -64,6 +65,7 @@ class Install extends Action
string $database,
string $installId,
?string $retryStep,
bool $migrate,
Request $request,
Response $response,
SwooleResponse $swooleResponse,
@@ -321,6 +323,28 @@ class Install extends Action
}
};
$responseSent = false;
$onComplete = function () use ($wantsStream, $swooleResponse, $response, $installId, $state, &$responseSent) {
if ($responseSent) {
return;
}
$responseSent = true;
$state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]);
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->write(": keepalive\n\n");
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->end();
} else {
$response->json([
'success' => true,
'installId' => $installId,
'message' => 'Installation completed successfully',
]);
}
};
$installer->performInstallation(
$httpPort ?: $config->getDefaultHttpPort(),
$httpsPort ?: $config->getDefaultHttpsPort(),
@@ -331,23 +355,12 @@ class Install extends Action
$progress,
$retryStep,
$config->isUpgrade(),
$account
$account,
$onComplete,
$migrate,
);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]);
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->write(": keepalive\n\n");
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->end();
} else {
$response->json([
'success' => true,
'installId' => $installId,
'message' => 'Installation completed successfully',
]);
}
$state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
$onComplete();
} catch (\Throwable $e) {
$this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state);
}
@@ -24,7 +24,7 @@ class View extends Action
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/')
->desc('Serve installer UI')
->param('step', 1, new Integer(true), 'Step number (1-5)', true)
->param('step', 1, new Integer(true), 'Step number (1-6)', true)
->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true)
->inject('request')
->inject('response')
@@ -52,10 +52,13 @@ class View extends Action
$defaultEmailCertificates = 'walterobrien@example.com';
}
$step = max(1, min(5, $step));
$step = max(1, min(6, $step));
if ($isUpgrade && ($step === 2 || $step === 3)) {
$step = 4;
}
if (!$isUpgrade && $step === 6) {
$step = 4;
}
$partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml";
if (!is_file($partialFile)) {
+20 -16
View File
@@ -6,6 +6,7 @@ use Appwrite\Platform\Installer\Http\Installer\Error;
use Appwrite\Platform\Installer\Runtime\Config;
use Appwrite\Platform\Installer\Runtime\State;
use Swoole\Http\Server as SwooleServer;
use Swoole\Runtime;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Http\Adapter\Swoole\Server as SwooleAdapter;
@@ -28,6 +29,7 @@ class Server
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
public const string STEP_ACCOUNT_SETUP = 'account-setup';
public const string STEP_MIGRATION = 'migration';
public const string STEP_SSL_CERTIFICATE = 'ssl-certificate';
public const string STATUS_IN_PROGRESS = 'in-progress';
@@ -129,6 +131,8 @@ class Server
private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void
{
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
$this->state->clearStaleLock();
// Preload static files into memory
@@ -141,9 +145,20 @@ class Server
$paths = $this->paths;
$state = $this->state;
Http::setResource('installerState', fn () => $state);
Http::setResource('installerConfig', fn () => $config);
Http::setResource('installerPaths', fn () => $paths);
$adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter {
public function getNativeServer(): SwooleServer
{
return $this->server;
}
};
$nativeServer = $adapter->getNativeServer();
$container = $adapter->getContainer();
$container->set('installerState', fn () => $state);
$container->set('installerConfig', fn () => $config);
$container->set('installerPaths', fn () => $paths);
$container->set('swooleServer', fn () => $nativeServer);
// Register routes via Utopia Platform
$platform = new Installer();
@@ -156,17 +171,6 @@ class Server
->inject('response')
->action($errorHandler->action(...));
$adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter {
public function getNativeServer(): SwooleServer
{
return $this->server;
}
};
$nativeServer = $adapter->getNativeServer();
Http::setResource('swooleServer', fn () => $nativeServer);
$nativeServer->on('start', function () use ($nativeServer, $port, $readyFile) {
\Swoole\Process::signal(SIGTERM, fn () => $nativeServer->shutdown());
\Swoole\Process::signal(SIGINT, fn () => $nativeServer->shutdown());
@@ -176,7 +180,7 @@ class Server
}
});
$adapter->onRequest(function (Request $request, Response $response) use ($files) {
$adapter->onRequest(function (Request $request, Response $response) use ($adapter, $files) {
// Serve static files from memory
$uri = $request->getURI();
if ($files->isFileLoaded($uri)) {
@@ -186,7 +190,7 @@ class Server
return;
}
$app = new Http('UTC');
$app = new Http($adapter, 'UTC');
$app->run($request, $response);
});
@@ -49,7 +49,6 @@ class Action extends PlatformAction
$image = new Image(\file_get_contents($path));
$image->crop((int) $width, (int) $height);
$output = (empty($output)) ? $type : $output;
$data = $image->output($output, $quality);
$response
->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
@@ -204,7 +204,6 @@ class Get extends Action
$image = new Image($data);
$image->crop((int) $width, (int) $height);
$output = (empty($output)) ? $type : $output;
$data = $image->output($output, $quality);
$response
@@ -95,7 +95,6 @@ class Get extends Action
}
$image->crop((int) $width, (int) $height);
$output = (empty($output)) ? $type : $output;
$data = $image->output($output, $quality);
$response
@@ -90,7 +90,7 @@ class Get extends Action
}
}
$rand = \substr($code, -1);
$rand = (int) \substr((string) $code, -1);
$rand = ($rand > \count($themes) - 1) ? $rand % \count($themes) : $rand;
@@ -17,7 +17,7 @@ class Action extends AppwriteAction
return $this->context;
}
public function setHttpPath(string $path): AppwriteAction
public function setHttpPath(string $path): self
{
if (\str_contains($path, '/tablesdb')) {
$this->context = DATABASE_TYPE_TABLESDB;
@@ -28,7 +28,8 @@ class Action extends AppwriteAction
if (\str_contains($path, '/vectorsdb')) {
$this->context = DATABASE_TYPE_VECTORSDB;
}
return parent::setHttpPath($path);
parent::setHttpPath($path);
return $this;
}
/**
@@ -24,7 +24,7 @@ abstract class Action extends DatabasesAction
*/
abstract protected function getResponseModel(): string;
public function setHttpPath(string $path): DatabasesAction
public function setHttpPath(string $path): self
{
if (str_contains($path, '/tablesdb/')) {
$this->context = ROWS;
@@ -47,7 +47,8 @@ abstract class Action extends DatabasesAction
],
];
return parent::setHttpPath($path);
parent::setHttpPath($path);
return $this;
}
protected function getDatabasesOperationReadMetric(): string
@@ -406,8 +407,6 @@ abstract class Action extends DatabasesAction
if (\is_array($related)) {
$document->setAttribute($relationship->getAttribute('key'), \array_values($relations));
} elseif (empty($relations)) {
$document->setAttribute($relationship->getAttribute('key'), null);
}
}
@@ -87,13 +87,14 @@ class Decrement extends Action
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -87,13 +87,14 @@ class Increment extends Action
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -139,7 +139,7 @@ class Create extends Action
->inject('eventProcessor')
->callback($this->action(...));
}
public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Document $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
@@ -183,8 +183,8 @@ class Create extends Action
$documents = [$data];
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($isBulk && !$isAPIKey && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
@@ -209,7 +209,7 @@ class Create extends Action
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk create is not supported for ' . $this->getSDKNamespace() . ' with relationship ' . $this->getStructureContext());
}
$setPermissions = function (Document $document, ?array $permissions) use ($user, $isAPIKey, $isPrivilegedUser, $isBulk, $dbForProject, $authorization) {
$setPermissions = function (Document $document, ?array $permissions) use ($user, $isAPIKey, $isPrivilegedUser, $isBulk, $authorization) {
$allowedPermissions = [
Database::PERMISSION_READ,
Database::PERMISSION_UPDATE,
@@ -85,6 +85,7 @@ class Delete extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -101,12 +102,13 @@ class Delete extends Action
Context $usage,
TransactionState $transactionState,
array $plan,
Authorization $authorization
Authorization $authorization,
User $user
): void {
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
@@ -72,13 +72,14 @@ class Get extends Action
->inject('usage')
->inject('transactionState')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, User $user): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -89,10 +89,11 @@ class Update extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
@@ -102,8 +103,8 @@ class Update extends Action
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
@@ -121,7 +122,6 @@ class Update extends Action
$dbForDatabases = $getDatabasesDB($database);
// Read permission should not be required for update
/** @var Document $document */
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
if ($transactionId !== null) {
@@ -96,7 +96,7 @@ class Upsert extends Action
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, User $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
@@ -108,8 +108,8 @@ class Upsert extends Action
throw new Exception($this->getMissingPayloadException());
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -83,10 +83,10 @@ class XList extends Action
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -147,7 +147,7 @@ class XList extends Action
$cacheKeyBase = \sprintf(
'%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s',
$dbForProject->getCacheName(),
$hostname ?? '',
$hostname,
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$collectionId,
@@ -99,8 +99,6 @@ class Update extends Action
// Map aggregate permissions into the multiple permissions they represent.
$permissions = Permission::aggregate($permissions);
$enabled ??= $collection->getAttribute('enabled', true);
$collection = $dbForProject->updateDocument(
'database_' . $database->getSequence(),
$collectionId,
@@ -103,6 +103,9 @@ class XList extends Action
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
$deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : '';
$deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : '';
$deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : '';
$output[$i] = new Document([
'event' => $log['event'],
@@ -121,9 +124,9 @@ class XList extends Action
'clientVersion' => $client['clientVersion'],
'clientEngine' => $client['clientEngine'],
'clientEngineVersion' => $client['clientEngineVersion'],
'deviceName' => $device['deviceName'],
'deviceBrand' => $device['deviceBrand'],
'deviceModel' => $device['deviceModel'],
'deviceName' => $deviceName,
'deviceBrand' => $deviceBrand,
'deviceModel' => $deviceModel,
]);
$record = $geodb->get($log['ip']);
@@ -33,7 +33,7 @@ abstract class Action extends DatabasesAction
return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES;
}
public function setHttpPath(string $path): DatabasesAction
public function setHttpPath(string $path): self
{
switch (true) {
case str_contains($path, '/tablesdb'):
@@ -50,7 +50,8 @@ abstract class Action extends DatabasesAction
$this->databaseType = VECTORSDB;
break;
}
return parent::setHttpPath($path);
parent::setHttpPath($path);
return $this;
}
/**
@@ -65,17 +65,18 @@ class Create extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization): void
public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void
{
if (empty($operations)) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty');
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
// API keys and admins can read any transaction, regular users need permissions
$transaction = ($isAPIKey || $isPrivilegedUser)
@@ -238,7 +239,7 @@ class Create extends Action
}
}
$transaction = $authorization->skip(fn () => $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged, $existing, $operations) {
$transaction = $authorization->skip(fn () => $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged, $operations) {
$dbForProject->createDocuments('transactionLogs', $staged);
return $dbForProject->increaseDocumentAttribute(
'transactions',
@@ -91,7 +91,7 @@ class Update extends Action
* @param UtopiaResponse $response
* @param Database $dbForProject
* @param callable $getDatabasesDB
* @param Document $user
* @param User $user
* @param TransactionState $transactionState
* @param Delete $queueForDeletes
* @param Event $queueForEvents
@@ -105,11 +105,10 @@ class Update extends Action
* @throws Exception
* @throws \Throwable
* @throws \Utopia\Database\Exception
* @throws Authorization
* @throws Structure
* @throws StructureException
* @throws \Utopia\Http\Exception
*/
public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
{
if (!$commit && !$rollback) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true');
@@ -118,8 +117,8 @@ class Update extends Action
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time');
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$transaction = ($isAPIKey || $isPrivilegedUser)
? $authorization->skip(fn () => $dbForProject->getDocument('transactions', $transactionId))
@@ -183,19 +182,33 @@ class Update extends Action
$dbForDatabases = $getDatabasesDB($databaseDoc);
try {
$dbForDatabases->withTransaction(function () use ($dbForDatabases, $dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $usage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) {
$authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([
'status' => 'committing',
])));
$transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
'transactions',
$transactionId,
new Document(['status' => 'committing'])
));
$operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [
Query::equal('transactionInternalId', [$transaction->getSequence()]),
Query::orderAsc(),
Query::limit(PHP_INT_MAX),
]));
$operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [
Query::equal('transactionInternalId', [$transaction->getSequence()]),
Query::orderAsc(),
Query::limit(PHP_INT_MAX),
]));
$collections = [];
foreach ($operations as $operation) {
$databaseInternalId = $operation['databaseInternalId'];
$collectionInternalId = $operation['collectionInternalId'];
$collectionId = "database_{$databaseInternalId}_collection_{$collectionInternalId}";
if (!isset($collections[$collectionId])) {
$collections[$collectionId] = $authorization->skip(
fn () => $dbForProject->getCollection($collectionId)
);
}
}
$dbForDatabases->withTransaction(function () use ($dbForDatabases, $transactionState, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $collections) {
$state = [];
$collections = [];
foreach ($operations as $operation) {
$databaseInternalId = $operation['databaseInternalId'];
@@ -211,11 +224,6 @@ class Update extends Action
$data = $data->getArrayCopy();
}
if (!isset($collections[$collectionId])) {
$collections[$collectionId] = $authorization->skip(
fn () => $dbForProject->getCollection($collectionId)
);
}
$collection = $collections[$collectionId];
if (\is_array($data) && !empty($data)) {
@@ -277,16 +285,17 @@ class Update extends Action
}
}
$transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
'transactions',
$transactionId,
new Document(['status' => 'committed'])
));
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
->setDocument($transaction);
});
$transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
'transactions',
$transactionId,
new Document(['status' => 'committed'])
));
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
->setDocument($transaction);
} catch (NotFoundException $e) {
$authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([
'status' => 'failed',
@@ -68,6 +68,7 @@ class Decrement extends DecrementDocumentAttribute
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -68,6 +68,7 @@ class Increment extends IncrementDocumentAttribute
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -71,6 +71,7 @@ class Delete extends DocumentDelete
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}

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