diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa6dbe2bc3..2aeae21655 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,8 +150,37 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress --ignore-platform-reqs + - name: Cache PHPStan result cache + uses: actions/cache@v4 + with: + path: .phpstan-cache + key: phpstan-${{ github.sha }} + restore-keys: | + phpstan- + - name: Run PHPStan - run: composer analyze + run: composer analyze -- --no-progress + + specs: + name: Checks / Specs + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: swoole + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Generate specs + run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no locale: name: Checks / Locale @@ -451,6 +480,10 @@ jobs: _APP_BROWSER_HOST: http://invalid-browser/v1 _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -525,6 +558,10 @@ jobs: _APP_OPTIONS_ABUSE: enabled _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -582,6 +619,10 @@ jobs: env: _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable diff --git a/.gitignore b/.gitignore index d6e138a382..6846e19aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ appwrite.config.json /app/config/specs/ /docs/examples/ .phpunit.cache +.phpstan-cache playwright-report test-results docker-compose.web-installer.yml diff --git a/AGENTS.md b/AGENTS.md index bb24d9f4fe..4d11ff0ee3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,107 +1,120 @@ -# AGENTS.md +# Appwrite -Appwrite is an end-to-end backend server for web, mobile, native, and backend apps. This guide provides context and instructions for AI coding agents working on the Appwrite codebase. +Self-hosted Backend-as-a-Service platform. Hybrid monolithic-microservice architecture built with PHP 8.3+ on Swoole, delivered as Docker containers. -## Project Overview +## Commands -Appwrite is a self-hosted Backend-as-a-Service (BaaS) platform that provides developers with a set of APIs and tools to build secure, scalable applications. The project uses a hybrid monolithic-microservice architecture built with PHP, running on Swoole for high performance. +| Command | Purpose | +|---------|---------| +| `docker compose up -d --force-recreate --build` | Build and start all services | +| `docker compose exec appwrite test tests/e2e/Services/[Service]` | Run E2E tests for a service | +| `docker compose exec appwrite test tests/e2e/Services/[Service] --filter=[Method]` | Run a single test method | +| `docker compose exec appwrite test tests/unit/` | Run unit tests | +| `composer format` | Auto-format code (Pint, PSR-12) | +| `composer format ` | Format a specific file | +| `composer lint ` | Check formatting of a file | +| `composer analyze` | Static analysis (PHPStan level 3) | +| `composer check` | Same as `analyze` | -**Key Technologies:** -- **Backend:** PHP 8.3+, Swoole -- **Libraries:** Utopia PHP -- **Database:** MariaDB, Redis -- **Cache:** Redis -- **Queue:** Redis -- **Containers:** Docker +## Stack -## Development Commands +- PHP 8.3+, Swoole 6.x (async runtime, replaces PHP-FPM) +- Utopia PHP framework (HTTP routing, CLI, DI, queue) +- MongoDB (default), MariaDB, MySQL, PostgreSQL (adapters via utopia-php/database) +- Redis (cache, queue, pub/sub) +- Docker + Traefik (reverse proxy) +- PHPUnit 12, Pint (PSR-12), PHPStan level 3 -```bash -# Run Appwrite -docker compose up -d --force-recreate --build +## Project layout -# Run specific test -docker compose exec appwrite test /usr/src/code/tests/e2e/Services/[ServiceName] --filter=[FunctionName] +- **src/Appwrite/Platform/Modules/** -- feature modules (Account, Avatars, Compute, Console, Databases, Functions, Health, Project, Projects, Proxy, Sites, Storage, Teams, Tokens, VCS, Webhooks) +- **src/Appwrite/Platform/Workers/** -- background job workers +- **src/Appwrite/Platform/Tasks/** -- CLI tasks +- **app/init.php** -- bootstrap (registers services, resources, listeners) +- **app/init/** -- configs, constants, locales, models, registers, resources, span, database filters/formats +- **bin/** -- CLI entry points: `worker-*` (14 workers), `schedule-*`, `queue-*`, plus `doctor`, `install`, `migrate`, `realtime`, `upgrade`, `ssl`, `vars`, `maintenance`, `interval`, `specs`, `sdks`, etc. +- **tests/e2e/** -- end-to-end tests per service +- **tests/unit/** -- unit tests +- **public/** -- static assets and generated SDKs -# Format code -composer format +## Module structure + +Each module under `src/Appwrite/Platform/Modules/{Name}/` contains: + +``` +Module.php -- registers all services for the module +Services/Http.php -- registers HTTP endpoints +Services/Workers.php -- registers background workers +Services/Tasks.php -- registers CLI tasks +Http/{Service}/ -- endpoint actions (Create.php, Get.php, Update.php, Delete.php, XList.php) +Workers/ -- worker implementations +Tasks/ -- CLI task implementations ``` -## Code Style Guidelines +HTTP endpoint nesting reflects the URL path. Sub-resources get subdirectories. For example, within the Functions module: +`Http/Deployments/Template/Create.php` -> `POST /v1/functions/:functionId/deployments/template` -- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard -- Use PSR-4 autoloading -- Strict type declarations where applicable -- Comprehensive PHPDoc comments +File names in Http directories must only be `Get.php`, `Create.php`, `Update.php`, `Delete.php`, or `XList.php`. For non-CRUD operations, model the endpoint as a property update. For example, updating a team membership status lives at `Teams/Http/Memberships/Status/Update.php` (`PATCH /v1/teams/:teamId/memberships/:membershipId/status`). -### Naming Conventions +Register new modules in `src/Appwrite/Platform/Appwrite.php`. Detailed module guide: `src/Appwrite/Platform/AGENTS.md`. -#### `resourceType` Naming Rule +## Action pattern (HTTP endpoints) -When a collection has a combination of `resourceType`, `resourceId`, and/or `resourceInternalId`, the value of `resourceType` MUST always be **plural** - for example: `functions`, `sites`, `deployments`. - -Examples: ```php -'resourceType' => 'functions' -'resourceType' => 'sites' -'resourceType' => 'deployments' +class Create extends Action +{ + public static function getName(): string { return 'createTeam'; } + + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/teams') + ->desc('Create team') + ->groups(['api', 'teams']) + ->label('event', 'teams.[teamId].create') + ->label('scope', 'teams.write') + ->param('teamId', '', new CustomId(), 'Team ID.') + ->param('name', null, new Text(128), 'Team name.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $teamId, + string $name, + Response $response, + Database $dbForProject, + Event $queueForEvents, + ): void { + // implementation + } +} ``` -## Performance Patterns +Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `$user`, `$project`, `$queueForEvents`, `$queueForMails`, `$queueForDeletes`. -### Document Update Optimization +## Conventions -When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`. +- PSR-12 formatting enforced by Pint. PSR-4 autoloading. +- `resourceType` values are always **plural**: `'functions'`, `'sites'`, `'deployments'`. +- When updating documents, pass only changed attributes as a sparse Document: + ```php + // correct + $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'name' => $name, + ])); + // incorrect -- passing full document is inefficient + $user->setAttribute('name', $name); + $dbForProject->updateDocument('users', $user->getId(), $user); + ``` + Exceptions: migrations, `array_merge()` with `getArrayCopy()`, updates where nearly all attributes change, complex nested relationship logic requiring full document state. +- Avoid introducing dependencies outside the `utopia-php` ecosystem. +- Never hardcode credentials -- use environment variables. +- Code changes may require container restart. No central log location -- check relevant containers. -**Correct Pattern:** -```php -// Good: Pass only changed attributes directly -$user = $dbForProject->updateDocument('users', $user->getId(), new Document([ - 'name' => $name, - 'email' => $email, -])); -``` +## Cross-repo context -**Incorrect Pattern:** -```php -$user->setAttribute('name', $name); -$user->setAttribute('email', $email); - -// Bad: Passing full document is inefficient -$user = $dbForProject->updateDocument('users', $user->getId(), $user); -``` - -**Exceptions:** -- Migration files (need full document updates by design) -- Cases already using `array_merge()` with `getArrayCopy()` -- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document) -- Complex nested relationship logic where full document state is required - -## Security Considerations - -### Critical Security Practices - -- **Never hardcode credentials** - Use environment variables -- **Rate limiting** - Respect abuse prevention mechanisms - -## Dependencies - -Avoid introducing new dependencies other than utopia-php. - -## Adding new endpoints - -When adding new endpoints, make sure to use modules and follow its patterns. Find instruction in [Modules AGENTS.md](src/Appwrite/Platform/AGENTS.md) file. - -## Pull Request Guidelines -### Before Submitting - -- Run `composer format` -- Update documentation if adding features -- Add/update tests for your changes -- Check that Docker build succeeds -`docs/specs/authentication.drawio.svg` - -## Known Issues and Gotchas - -- **Hot Reload:** Code changes require container restart in some cases -- **Logging:** There is no central place for logs, so when debugging, ensure to check all possibly relevant containers +Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console. diff --git a/README.md b/README.md index 457863d236..88d527f060 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,28 @@ -> We just announced DB operators for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-db-operators) - -> Appwrite Cloud is now Generally Available - [Learn more](https://appwrite.io/cloud-ga) - -> [Get started with Appwrite](https://apwr.dev/appcloud) +image

- Appwrite banner, with logo and text saying -
-
- Appwrite is a best-in-class, developer-first platform that gives builders everything they need to create scalable, stable, and production-ready software, fast. +

Appwrite

+ Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.

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

Database migration

+

+ Run database migration after the update to apply schema changes. +

+
+ +
+ + +
+ + + + + To run manually later: docker compose exec appwrite migrate + +
+
+
+
diff --git a/app/worker.php b/app/worker.php index 71446ee94f..e55abb587c 100644 --- a/app/worker.php +++ b/app/worker.php @@ -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', ''); - - // 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']); - -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) { diff --git a/composer.json b/composer.json index 65838a1615..4ad1ae6120 100644 --- a/composer.json +++ b/composer.json @@ -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/http": "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": "*", diff --git a/composer.lock b/composer.lock index 17305e6a90..164b3a036f 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "4fb974e9843f6104e40396e7cad4a833", "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", @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -3403,16 +3403,16 @@ }, { "name": "utopia-php/agents", - "version": "1.3.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/agents.git", - "reference": "06064fd9fb19b77ae45a12ec7bcbc17670912c30" + "reference": "052227953678a30ecc4b5467401fcb0b2386471e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/agents/zipball/06064fd9fb19b77ae45a12ec7bcbc17670912c30", - "reference": "06064fd9fb19b77ae45a12ec7bcbc17670912c30", + "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e", + "reference": "052227953678a30ecc4b5467401fcb0b2386471e", "shasum": "" }, "require": { @@ -3450,9 +3450,9 @@ ], "support": { "issues": "https://github.com/utopia-php/agents/issues", - "source": "https://github.com/utopia-php/agents/tree/1.3.0" + "source": "https://github.com/utopia-php/agents/tree/1.2.1" }, - "time": "2026-03-26T03:51:11+00:00" + "time": "2026-02-24T06:03:55+00:00" }, { "name": "utopia-php/analytics", @@ -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", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.17", + "version": "5.3.19", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993" + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/cff2b6ed63d3291b74110d086e16ff089fe05993", - "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993", + "url": "https://api.github.com/repos/utopia-php/database/zipball/72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.17" + "source": "https://github.com/utopia-php/database/tree/5.3.19" }, - "time": "2026-03-20T01:18:52+00:00" + "time": "2026-03-31T15:52:08+00:00" }, { "name": "utopia-php/detector", @@ -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", @@ -4267,31 +4270,35 @@ "time": "2025-12-18T16:25:10+00:00" }, { - "name": "utopia-php/framework", - "version": "0.33.41", + "name": "utopia-php/http", + "version": "0.34.19", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06" + "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" }, "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/995c119f31866cacd42d63b1f922bf86eabb396c", + "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", "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,18 @@ "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.19" }, - "time": "2026-02-24T12:01:28+00:00" + "time": "2026-04-08T10:23:17+00:00" }, { "name": "utopia-php/image", @@ -4467,23 +4475,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 +4520,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 +4575,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 +4642,30 @@ }, { "name": "utopia-php/platform", - "version": "0.7.16", + "version": "0.12.1", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "34e67e4b80b5741c380071fe765fbc12a132de4f" + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" }, "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/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", "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 +4687,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.1" }, - "time": "2026-02-11T06:36:48+00:00" + "time": "2026-04-08T04:11:31+00:00" }, { "name": "utopia-php/pools", @@ -4791,32 +4799,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 +4860,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 +4918,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 +4966,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 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.14.0", + "version": "1.17.7", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" + "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", "shasum": "" }, "require": { @@ -5484,22 +5493,22 @@ "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.14.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" }, - "time": "2026-03-26T12:50:11+00:00" + "time": "2026-04-08T08:51:05+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -5523,7 +5532,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -5567,7 +5576,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -5579,7 +5588,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "czproject/git-php", @@ -6195,11 +6204,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -6244,7 +6253,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6594,16 +6603,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.17", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b", + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b", "shasum": "" }, "require": { @@ -6625,7 +6634,7 @@ "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6672,31 +6681,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-04-08T03:04:19+00:00" }, { "name": "sebastian/cli-parser", @@ -6769,16 +6762,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", "shasum": "" }, "require": { @@ -6837,7 +6830,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" }, "funding": [ { @@ -6857,7 +6850,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-08T04:43:00+00:00" }, { "name": "sebastian/complexity", @@ -7681,16 +7674,16 @@ }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -7747,7 +7740,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -7767,7 +7760,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8101,16 +8094,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { @@ -8142,7 +8135,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -8162,20 +8155,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -8232,7 +8225,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8252,7 +8245,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "textalk/websocket", diff --git a/docs/references/health/get-queue-audits.md b/docs/references/health/get-queue-audits.md index 75010cc2f4..bac075581f 100644 --- a/docs/references/health/get-queue-audits.md +++ b/docs/references/health/get-queue-audits.md @@ -1 +1 @@ -Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file +Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server. diff --git a/docs/references/migrations/migration-json-export.md b/docs/references/migrations/migration-json-export.md new file mode 100644 index 0000000000..8a955c5990 --- /dev/null +++ b/docs/references/migrations/migration-json-export.md @@ -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. diff --git a/docs/references/migrations/migration-json-import.md b/docs/references/migrations/migration-json-import.md new file mode 100644 index 0000000000..2eeeaf5619 --- /dev/null +++ b/docs/references/migrations/migration-json-import.md @@ -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. diff --git a/docs/sdks/markdown/CHANGELOG.md b/docs/sdks/markdown/CHANGELOG.md deleted file mode 100644 index 26fcb5bbce..0000000000 --- a/docs/sdks/markdown/CHANGELOG.md +++ /dev/null @@ -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 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 5e5ab5eef4..0000000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,1756 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: app/config/templates/function.php - - - - message: '#^Array has 2 duplicate keys with value ''outputDirectory'' \(''outputDirectory'', ''outputDirectory''\)\.$#' - identifier: array.duplicateKey - count: 3 - path: app/config/templates/site.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: app/config/templates/site.php - - - - message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' - identifier: method.void - count: 2 - path: app/controllers/api/account.php - - - - message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: app/controllers/api/account.php - - - - message: '#^Variable \$session might not be defined\.$#' - identifier: variable.undefined - count: 3 - path: app/controllers/api/account.php - - - - message: '#^Variable \$output might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: app/controllers/api/locale.php - - - - message: '#^Variable \$currentScheduledAt on left side of \?\? is never defined\.$#' - identifier: nullCoalesce.variable - count: 1 - path: app/controllers/api/messaging.php - - - - message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: app/controllers/api/projects.php - - - - message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' - identifier: method.void - count: 2 - path: app/controllers/api/users.php - - - - message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: app/controllers/api/users.php - - - - message: '#^Result of method Utopia\\Http\\Response\:\:redirect\(\) \(void\) is used\.$#' - identifier: method.void - count: 2 - path: app/controllers/general.php - - - - message: '#^Result of method Utopia\\Http\\Response\:\:send\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: app/controllers/general.php - - - - message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: app/controllers/general.php - - - - message: '#^Variable \$deployment in PHPDoc tag @var does not exist\.$#' - identifier: varTag.variableNotFound - count: 1 - path: app/controllers/general.php - - - - message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: app/controllers/general.php - - - - message: '#^Variable \$executionResponse might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: app/controllers/general.php - - - - message: '#^Variable \$user might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: app/controllers/general.php - - - - message: '#^Variable \$installation might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: app/controllers/mock.php - - - - message: '#^Call to an undefined method Utopia\\Database\\Document\:\:getRoles\(\)\.$#' - identifier: method.notFound - count: 1 - path: app/controllers/shared/api.php - - - - message: '#^Variable \$register might not be defined\.$#' - identifier: variable.undefined - count: 3 - path: app/http.php - - - - message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#' - identifier: nullCoalesce.variable - count: 1 - path: app/init/database/filters.php - - - - message: '#^Anonymous function has an unused use \$dsn\.$#' - identifier: closure.unusedUse - count: 1 - path: app/init/registers.php - - - - message: '#^Binary operation "/" between string and string results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: app/init/registers.php - - - - message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$Port \(int\) does not accept string\.$#' - identifier: assign.propertyType - count: 1 - path: app/init/registers.php - - - - message: '#^Variable \$providerConfig in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 2 - path: app/init/registers.php - - - - message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: app/init/registers.php - - - - message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: app/init/resources.php - - - - message: '#^Anonymous function has an unused use \$register\.$#' - identifier: closure.unusedUse - count: 4 - path: app/realtime.php - - - - message: '#^Class Utopia\\Database\\Validator\\Authorization does not have a constructor and must be instantiated without any parameters\.$#' - identifier: new.noConstructor - count: 1 - path: app/realtime.php - - - - message: '#^Function triggerStats\(\) returns void but does not have any side effects\.$#' - identifier: void.pure - count: 1 - path: app/realtime.php - - - - message: '#^Binary operation "\*" between \-1 and string results in an error\.$#' - identifier: binaryOp.invalid - count: 3 - path: app/worker.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type int\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Auth/OAuth2.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$token$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Auth/OAuth2/Disqus.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$value$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Auth/Validator/PersonalData.php - - - - message: '#^PHPDoc tag @param has invalid value \(DeviceDetector\)\: Unexpected token "\\n ", expected variable at offset 32 on line 2$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Detector/Detector.php - - - - message: '#^PHPDoc tag @param has invalid value \(string\)\: Unexpected token "\\n ", expected variable at offset 24 on line 2$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Detector/Detector.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Docker/Compose.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Docker/Compose/Service.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Docker/Env.php - - - - message: '#^PHPDoc tag @param has invalid value \(int port\)\: Unexpected token "port", expected variable at offset 50 on line 4$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$password$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type Appwrite\\Event\\Mail\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^Method Appwrite\\Event\\Message\\Usage\:\:fromArray\(\) should return static\(Appwrite\\Event\\Message\\Usage\) but returns Appwrite\\Event\\Message\\Usage\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Event/Message/Usage.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$message$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Event/Messaging.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Event/Messaging.php - - - - message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getFunctionsEvents\(\) should return array\ but returns array\\>\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Functions/EventProcessor.php - - - - message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getWebhooksEvents\(\) should return array\ but returns array\\>\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Functions/EventProcessor.php - - - - message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Functions/EventProcessor.php - - - - message: '#^Anonymous function has an unused use \$context\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/GraphQL/Resolvers.php - - - - message: '#^Anonymous function has an unused use \$info\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/GraphQL/Resolvers.php - - - - message: '#^Anonymous function has an unused use \$type\.$#' - identifier: closure.unusedUse - count: 5 - path: src/Appwrite/GraphQL/Resolvers.php - - - - message: '#^Variable \$request in PHPDoc tag @var does not exist\.$#' - identifier: varTag.variableNotFound - count: 1 - path: src/Appwrite/GraphQL/Resolvers.php - - - - message: '#^Variable \$response in PHPDoc tag @var does not exist\.$#' - identifier: varTag.variableNotFound - count: 1 - path: src/Appwrite/GraphQL/Resolvers.php - - - - message: '#^Variable \$databaseId might not be defined\.$#' - identifier: variable.undefined - count: 5 - path: src/Appwrite/GraphQL/Schema.php - - - - message: '#^Variable \$sdk in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/GraphQL/Schema.php - - - - message: '#^Method Appwrite\\GraphQL\\Types\:\:assoc\(\) should return Appwrite\\GraphQL\\Types\\Json but returns GraphQL\\Type\\Definition\\Type\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/GraphQL/Types.php - - - - message: '#^Method Appwrite\\GraphQL\\Types\:\:inputFile\(\) should return Appwrite\\GraphQL\\Types\\InputFile but returns GraphQL\\Type\\Definition\\Type\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/GraphQL/Types.php - - - - message: '#^Method Appwrite\\GraphQL\\Types\:\:json\(\) should return Appwrite\\GraphQL\\Types\\Json but returns GraphQL\\Type\\Definition\\Type\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/GraphQL/Types.php - - - - message: '#^Class Appwrite\\Network\\Validator\\CNAME not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Class Utopia\\Validator\\Origin not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe access to private property Appwrite\\GraphQL\\Types\\Mapper\:\:\$models through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getColumnImplementation\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 2 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getHashOptionsImplementation\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 1 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getUnionImplementation\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 1 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V15\:\:documentsIterator\(\)\.$#' - identifier: method.notFound - count: 7 - path: src/Appwrite/Migration/Version/V15.php - - - - message: '#^Method Appwrite\\Migration\\Version\\V15\:\:fixDocument\(\) should return Utopia\\Database\\Document but empty return statement found\.$#' - identifier: return.empty - count: 1 - path: src/Appwrite/Migration/Version/V15.php - - - - message: '#^PHPDoc tag @return with type string\|false is not subtype of native type string\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Migration/Version/V15.php - - - - message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Migration/Version/V15.php - - - - message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V17\:\:documentsIterator\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Appwrite/Migration/Version/V17.php - - - - message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V18\:\:documentsIterator\(\)\.$#' - identifier: method.notFound - count: 2 - path: src/Appwrite/Migration/Version/V18.php - - - - message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V19\:\:documentsIterator\(\)\.$#' - identifier: method.notFound - count: 4 - path: src/Appwrite/Migration/Version/V19.php - - - - message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V20\:\:documentsIterator\(\)\.$#' - identifier: method.notFound - count: 6 - path: src/Appwrite/Migration/Version/V20.php - - - - message: '#^Variable \$query on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Migration/Version/V20.php - - - - message: '#^Method Appwrite\\Network\\Cors\:\:headers\(\) should return array\ but returns array\\.$#' - identifier: return.type - count: 5 - path: src/Appwrite/Network/Cors.php - - - - message: '#^Parameter &\$tag by\-ref type of method Appwrite\\OpenSSL\\OpenSSL\:\:encrypt\(\) expects null, string\|null given\.$#' - identifier: parameterByRef.type - count: 1 - path: src/Appwrite/OpenSSL/OpenSSL.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$projectId$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Platform/Action.php - - - - message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/Avatars/Http/Action.php - - - - message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php - - - - message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php - - - - message: '#^Binary operation "%%" between string and 5 results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php - - - - message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Action but returns Utopia\\Platform\\Action\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php - - - - message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Collections\\Documents\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action but returns Appwrite\\Platform\\Action\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php - - - - message: '#^Variable \$relations in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php - - - - message: '#^Anonymous function has an unused use \$dbForProject\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php - - - - message: '#^Variable \$document in PHPDoc tag @var does not match assigned variable \$collectionTableId\.$#' - identifier: varTag.differentVariable - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php - - - - message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php - - - - message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php - - - - message: '#^Offset ''deviceBrand'' does not exist on int\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php - - - - message: '#^Offset ''deviceModel'' does not exist on int\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php - - - - message: '#^Offset ''deviceName'' does not exist on int\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php - - - - message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Transactions\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action but returns Appwrite\\Platform\\Action\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php - - - - message: '#^Anonymous function has an unused use \$existing\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php - - - - message: '#^Anonymous function has an unused use \$queueForEvents\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - - message: '#^Anonymous function has an unused use \$queueForFunctions\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - - message: '#^Anonymous function has an unused use \$queueForRealtime\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - - message: '#^Anonymous function has an unused use \$queueForWebhooks\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - - message: '#^Anonymous function has an unused use \$usage\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - - message: '#^PHPDoc tag @throws with type Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Transactions\\Structure\|Throwable\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#' - identifier: throws.notThrowable - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - - message: '#^Offset ''deviceBrand'' does not exist on int\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php - - - - message: '#^Offset ''deviceModel'' does not exist on int\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php - - - - message: '#^Offset ''deviceName'' does not exist on int\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php - - - - message: '#^Variable \$device might not be defined\.$#' - identifier: variable.undefined - count: 5 - path: src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php - - - - message: '#^Variable \$path might not be defined\.$#' - identifier: variable.undefined - count: 5 - path: src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php - - - - message: '#^Call to method getAttribute\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Call to method getId\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Call to method isEmpty\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Method Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Create\:\:enqueueDeletes\(\) returns void but does not have any side effects\.$#' - identifier: void.pure - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^PHPDoc tag @var for variable \$session contains unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Variable \$jwt on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php - - - - message: '#^Call to an undefined method Appwrite\\Event\\Event\:\:setSubscribers\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php - - - - message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php - - - - message: '#^Undefined variable\: \$cpus$#' - identifier: variable.undefined - count: 3 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Undefined variable\: \$memory$#' - identifier: variable.undefined - count: 3 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Variable \$deployment might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Variable \$logsAfter on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Variable \$logsBefore on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Variable \$providerCommitHash on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php - - - - message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php - - - - message: '#^Variable \$device might not be defined\.$#' - identifier: variable.undefined - count: 5 - path: src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php - - - - message: '#^Variable \$path might not be defined\.$#' - identifier: variable.undefined - count: 5 - path: src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php - - - - message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php - - - - message: '#^Variable \$iv might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php - - - - message: '#^Variable \$tag might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php - - - - message: '#^Variable \$allowedFileExtensions on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php - - - - message: '#^Variable \$antivirus on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php - - - - message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php - - - - message: '#^Variable \$transformations on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php - - - - message: '#^Caught class Appwrite\\Platform\\Modules\\Teams\\Http\\Memberships\\Throwable not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php - - - - message: '#^Variable \$email in empty\(\) always exists and is always falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php - - - - message: '#^Variable \$hash might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php - - - - message: '#^Variable \$invitee might not be defined\.$#' - identifier: variable.undefined - count: 14 - path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php - - - - message: '#^Variable \$logBase might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php - - - - message: '#^Variable \$previewUrl in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php - - - - message: '#^Variable \$repositoryName in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php - - - - message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php - - - - message: '#^Result of method Utopia\\Http\\Response\:\:redirect\(\) \(void\) is used\.$#' - identifier: method.void - count: 2 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - - - message: '#^Undefined variable\: \$redirectFailure$#' - identifier: variable.undefined - count: 2 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - - - message: '#^Variable \$redirectFailure in empty\(\) is never defined\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php - - - - message: '#^Result of method Appwrite\\Utopia\\Response\:\:json\(\) \(void\) is used\.$#' - identifier: method.void - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - - message: '#^Variable \$logBase might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - - message: '#^Variable \$previewUrl in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - - message: '#^Variable \$repositoryName in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - - message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php - - - - message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Tasks/Doctor.php - - - - message: '#^Variable \$compose in isset\(\) always exists and is not nullable\.$#' - identifier: isset.variable - count: 1 - path: src/Appwrite/Platform/Tasks/Install.php - - - - message: '#^Variable \$prUrls might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Tasks/SDKs.php - - - - message: '#^Anonymous function has an unused use \$dbForPlatform\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Tasks/StatsResources.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$getProjectDB$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Platform/Workers/Audits.php - - - - message: '#^Anonymous function has an unused use \$certificates\.$#' - identifier: closure.unusedUse - count: 2 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^Anonymous function has an unused use \$dbForPlatform\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^Anonymous function has an unused use \$dbForProject\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^Anonymous function has an unused use \$project\.$#' - identifier: closure.unusedUse - count: 6 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^Anonymous function has an unused use \$resourceType\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$build$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$dbForPlatform$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$target$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^PHPDoc tag @throws with type Appwrite\\Extend\\Exception\|Utopia\\Database\\Exception\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#' - identifier: throws.notThrowable - count: 2 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^PHPDoc tag @throws with type Appwrite\\Extend\\Exception\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#' - identifier: throws.notThrowable - count: 1 - path: src/Appwrite/Platform/Workers/Deletes.php - - - - message: '#^PHPDoc tag @throws with type Appwrite\\Platform\\Workers\\Exception is not subtype of Throwable$#' - identifier: throws.notThrowable - count: 2 - path: src/Appwrite/Platform/Workers/Functions.php - - - - message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 2 - path: src/Appwrite/Platform/Workers/Functions.php - - - - message: '#^Variable \$errorCode might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Workers/Functions.php - - - - message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Workers/Functions.php - - - - message: '#^Binary operation "\+\=" between 0 and array\\|int\|string\>\|int\|string results in an error\.$#' - identifier: assignOp.invalid - count: 1 - path: src/Appwrite/Platform/Workers/Messaging.php - - - - message: '#^Variable \$provider might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Workers/Messaging.php - - - - message: '#^Variable \$aggregatedResources might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Appwrite/Platform/Workers/Migrations.php - - - - message: '#^Anonymous function has an unused use \$dbForLogs\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Workers/StatsResources.php - - - - message: '#^Anonymous function has an unused use \$sequence\.$#' - identifier: closure.unusedUse - count: 1 - path: src/Appwrite/Platform/Workers/StatsUsage.php - - - - message: '#^Callable callable\(\)\: Utopia\\Database\\Database invoked with 1 parameter, 0 required\.$#' - identifier: arguments.count - count: 2 - path: src/Appwrite/Platform/Workers/StatsUsage.php - - - - message: '#^Variable \$curlError on left side of \?\? is never defined\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Workers/Webhooks.php - - - - message: '#^Unsafe usage of new static\(\)\.$#' - identifier: new.static - count: 3 - path: src/Appwrite/Promises/Promise.php - - - - message: '#^Property Appwrite\\SDK\\Method\:\:\$hide on left side of \?\? is not nullable nor uninitialized\.$#' - identifier: nullCoalesce.initializedProperty - count: 1 - path: src/Appwrite/SDK/Method.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$services$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format.php - - - - message: '#^PHPDoc tag @return with type Appwrite\\SDK\\Specification\\Format is incompatible with native type array\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/SDK/Specification/Format.php - - - - message: '#^Cannot unset offset ''schema'' on array\{description\: ''No content'', content\?\: non\-empty\-array\<''''\|''\*/\*''\|''application/json''\|''image/\*''\|''image/png''\|''multipart/form\-data''\|''text/html''\|''text/plain'', array\{schema\: array\{''\$ref''\: non\-falsy\-string\}\}\|array\{schema\: array\{oneOf\: array\\}\}\>\}\.$#' - identifier: unset.offset - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Class Utopia\\Database\\Validator\\DatetimeValidator not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Class Utopia\\Validator\\Length not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Class Utopia\\Validator\\Mock not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Offset ''securityDefinitions'' on array\{openapi\: ''3\.0\.0'', info\: array\{version\: string, title\: string, description\: string, termsOfService\: string, contact\: array\{name\: string, url\: string, email\: string\}, license\: array\{name\: ''BSD\-3\-Clause'', url\: ''https\://raw…''\}\}, servers\: array\{array\{url\: string\}, array\{url\: string\}\}, paths\: array\{\}, tags\: array, components\: array\{schemas\: array\{\}, securitySchemes\: array\}, externalDocs\: array\{description\: string, url\: string\}\} in isset\(\) does not exist\.$#' - identifier: isset.offset - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Variable \$desc on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Variable \$validator in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php - - - - message: '#^Class Utopia\\Database\\Validator\\DatetimeValidator not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^Class Utopia\\Validator\\Length not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^Class Utopia\\Validator\\Mock not found\.$#' - identifier: class.notFound - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^PHPDoc tag @var with type Appwrite\\SDK\\Method is not subtype of native type \*NEVER\*\.$#' - identifier: varTag.nativeType - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^Variable \$additionalMethods in empty\(\) always exists and is always falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^Variable \$desc on left side of \?\?\= always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^Variable \$sdk in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^Variable \$validator in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 2 - path: src/Appwrite/Template/Template.php - - - - message: '#^PHPDoc tag @param has invalid value \(Document \$this\)\: Unexpected token "\$this", expected variable at offset 69 on line 4$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Utopia/Database/Documents/User.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$sessions$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Utopia/Database/Documents/User.php - - - - message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:appendSymbol\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 4 - path: src/Appwrite/Utopia/Request/Filters/V17.php - - - - message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:isSpecialChar\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 1 - path: src/Appwrite/Utopia/Request/Filters/V17.php - - - - message: '#^PHPDoc tag @param has invalid value \(callable The callback to show sensitive information for\)\: Unexpected token "The", expected variable at offset 91 on line 4$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Utopia/Response.php - - - - message: '#^PHPDoc tag @return with type Appwrite\\Utopia\\Response\\Filter is incompatible with native type array\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Utopia/Response.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Utopia/Response/Model/User.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Databases\\Legacy\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php - - - message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsGuestTest\:\:getIndexUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsGuestTest.php - - - - message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsMemberTest\:\:getIndexUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsMemberTest.php - - - - message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsTeamTest\:\:getIndexUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php - - - - message: '#^Method PHPUnit\\Framework\\TestCase\:\:addToAssertionCount\(\) invoked with 2 parameters, 1 required\.$#' - identifier: arguments.count - count: 1 - path: tests/e2e/Services/Functions/FunctionsCustomClientTest.php - - - - message: '#^Variable \$largeTag might not be defined\.$#' - identifier: variable.undefined - count: 8 - path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedDeployment through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/FunctionsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedExecution through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/FunctionsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedFunction through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/FunctionsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedDeployment through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/FunctionsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedExecution through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/FunctionsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedFunction through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/FunctionsServerTest.php - - - - message: '#^Binary operation "\+" between string and 1 results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: tests/e2e/Services/GraphQL/Legacy/AbuseTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$bulkData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$collection through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$database through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$document through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedEmail through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 2 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedProviders through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 8 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedPush through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSms through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSubscriber through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedTopic through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 9 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Variable \$from in empty\(\) is never defined\.$#' - identifier: empty.variable - count: 1 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#' - identifier: return.missing - count: 1 - path: tests/e2e/Services/GraphQL/StorageClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedBucket through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/StorageClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedFile through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/StorageClientTest.php - - - - message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#' - identifier: return.missing - count: 1 - path: tests/e2e/Services/GraphQL/StorageServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedBucket through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/StorageServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedFile through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 6 - path: tests/e2e/Services/GraphQL/StorageServerTest.php - - - - message: '#^Binary operation "\+" between string and 1 results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBooleanColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBulkData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatabase through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatetimeColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEmailColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEnumColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedFloatColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIPColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIndexData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 7 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIntegerColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRelationshipColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRowData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedStringColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedTableData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedURLColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedMembership through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TeamsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedTeam through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/TeamsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedMembership through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TeamsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeam through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/TeamsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeamWithPrefs through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/TeamsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUser through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/UsersTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUserTarget through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/UsersTest.php - - - - message: '#^Variable \$from in empty\(\) is never defined\.$#' - identifier: empty.variable - count: 1 - path: tests/e2e/Services/Messaging/MessagingConsoleClientTest.php - - - - message: '#^Variable \$from in empty\(\) is never defined\.$#' - identifier: empty.variable - count: 1 - path: tests/e2e/Services/Messaging/MessagingCustomClientTest.php - - - - message: '#^Variable \$from in empty\(\) is never defined\.$#' - identifier: empty.variable - count: 1 - path: tests/e2e/Services/Messaging/MessagingCustomServerTest.php - - - - message: '#^Anonymous function has an unused use \$databaseId\.$#' - identifier: closure.unusedUse - count: 5 - path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php - - - - message: '#^Anonymous function has an unused use \$tableId\.$#' - identifier: closure.unusedUse - count: 5 - path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php - - - - message: '#^Variable \$largeFile might not be defined\.$#' - identifier: variable.undefined - count: 8 - path: tests/e2e/Services/Storage/StorageConsoleClientTest.php - - - - message: '#^Variable \$largeFile might not be defined\.$#' - identifier: variable.undefined - count: 8 - path: tests/e2e/Services/Storage/StorageCustomClientTest.php - - - - message: '#^Variable \$largeFile might not be defined\.$#' - identifier: variable.undefined - count: 8 - path: tests/e2e/Services/Storage/StorageCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\TablesDB\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php - - - - message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsGuestTest\:\:getIndexUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsGuestTest.php - - - - message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsMemberTest\:\:getIndexUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsMemberTest.php - - - - message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsTeamTest\:\:getIndexUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$bucketAndFileData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$tokenData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomClientTest\:\:\$bucketAndFileData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensCustomClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$bucketAndFileData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$tokenData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedHashedPasswordUsers through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 2 - path: tests/e2e/Services/Users/UsersCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedUser through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 7 - path: tests/e2e/Services/Users/UsersCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedUserTarget through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 7 - path: tests/e2e/Services/Users/UsersCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userEmailUpdated through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/Users/UsersCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userNameUpdated through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/Users/UsersCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userNumberUpdated through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/Users/UsersCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\VCS\\VCSConsoleClientTest\:\:\$cachedFunctionData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/VCS/VCSConsoleClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\VCS\\VCSConsoleClientTest\:\:\$cachedInstallationId through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/VCS/VCSConsoleClientTest.php - - - - message: '#^Unsafe call to private method Tests\\Unit\\Auth\\KeyTest\:\:generateKey\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 3 - path: tests/unit/Auth/KeyTest.php - - - - message: '#^Call to an undefined method Utopia\\Queue\\Publisher\:\:getEvents\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/unit/Event/EventTest.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 6 - path: tests/unit/Utopia/Response/Filters/V16Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V16\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V16Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V16Test.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 5 - path: tests/unit/Utopia/Response/Filters/V17Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V17\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V17Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V17Test.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 4 - path: tests/unit/Utopia/Response/Filters/V18Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V18\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V18Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V18Test.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 11 - path: tests/unit/Utopia/Response/Filters/V19Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V19\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V19Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V19Test.php diff --git a/phpstan.neon b/phpstan.neon index b87ad46eca..85d18fd44d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -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 - diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 9358c89547..a8a2d175b5 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -155,7 +155,7 @@ abstract class OAuth2 /** * @param string $code * - * @return string + * @return int */ public function getAccessTokenExpiry(string $code): int { diff --git a/src/Appwrite/Auth/OAuth2/Disqus.php b/src/Appwrite/Auth/OAuth2/Disqus.php index 58b7f48914..738c6d503e 100644 --- a/src/Appwrite/Auth/OAuth2/Disqus.php +++ b/src/Appwrite/Auth/OAuth2/Disqus.php @@ -108,7 +108,7 @@ class Disqus extends OAuth2 } /** - * @param string $token + * @param string $accessToken * * @return string */ diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php new file mode 100644 index 0000000000..d12ce25b33 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -0,0 +1,320 @@ +state; + $state[self::PKCE_STATE_KEY] = $this->encryptPKCEVerifier($this->getPKCEVerifier()); + + return 'https://x.com/i/oauth2/authorize?' . \http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => $this->base64UrlEncode(\json_encode($state, JSON_THROW_ON_ERROR)), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $this->tokens = $this->decodeJsonObject($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $this->tokenEndpointHeaders(), + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + ]) + )); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $this->tokens = $this->decodeJsonObject($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $this->tokenEndpointHeaders(), + \http_build_query([ + 'client_id' => $this->appID, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]) + )); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['id'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['confirmed_email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * X returns a confirmed email only when the app has email access enabled + * and the authenticated user has a confirmed email address. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $this->user = $this->decodeJsonObject($this->request( + 'GET', + 'https://api.x.com/2/users/me?user.fields=confirmed_email', + ['Authorization: Bearer ' . $accessToken] + )); + } + + return $this->user; + } + + /** + * @return array|null + */ + public function parseState(string $state): ?array + { + $decoded = $this->base64UrlDecode($state); + if ($decoded === false) { + return null; + } + + $parsed = \json_decode($decoded, true); + + if (!\is_array($parsed)) { + return null; + } + + $pkce = $parsed[self::PKCE_STATE_KEY] ?? null; + + if (\is_array($pkce)) { + $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); + } + + unset($parsed[self::PKCE_STATE_KEY]); + + return $parsed; + } + + /** + * @return list + */ + private function tokenEndpointHeaders(): array + { + return [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + } + + /** + * @return array + */ + private function decodeJsonObject(string $json): array + { + $decoded = \json_decode($json, true); + + return \is_array($decoded) ? $decoded : []; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(64)); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + + private function encryptPKCEVerifier(string $verifier): array + { + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $key = $this->getPKCEStateKey(); + $tag = null; + + $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); + + if ($data === false || $tag === null) { + throw new \Exception('Failed to encrypt PKCE verifier.'); + } + + return [ + 'data' => $this->base64UrlEncode($data), + 'iv' => \bin2hex($iv), + 'tag' => \bin2hex($tag), + ]; + } + + private function decryptPKCEVerifier(array $payload): string + { + $data = $payload['data'] ?? ''; + $iv = $payload['iv'] ?? ''; + $tag = $payload['tag'] ?? ''; + + if ($data === '' || $iv === '' || $tag === '') { + return ''; + } + + $decodedData = $this->base64UrlDecode($data); + $decodedIv = \hex2bin($iv); + $decodedTag = \hex2bin($tag); + + if ($decodedData === false || $decodedIv === false || $decodedTag === false) { + return ''; + } + + return OpenSSL::decrypt( + $decodedData, + OpenSSL::CIPHER_AES_128_GCM, + $this->getPKCEStateKey(), + OPENSSL_RAW_DATA, + $decodedIv, + $decodedTag + ) ?: ''; + } + + private function getPKCEStateKey(): string + { + $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); + + if ($key === '') { + throw new \Exception('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + } + + return $key; + } + + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): string|false + { + $padding = \strlen($value) % 4; + if ($padding > 0) { + $value .= \str_repeat('=', 4 - $padding); + } + + return \base64_decode(\strtr($value, '-_', '+/'), true); + } + +} diff --git a/src/Appwrite/Auth/OAuth2/Yahoo.php b/src/Appwrite/Auth/OAuth2/Yahoo.php index c70a2fb6c9..1a6c6b860d 100644 --- a/src/Appwrite/Auth/OAuth2/Yahoo.php +++ b/src/Appwrite/Auth/OAuth2/Yahoo.php @@ -23,8 +23,9 @@ class Yahoo extends OAuth2 * @var array */ protected array $scopes = [ - 'sdct-r', - 'sdpp-w', + 'openid', + 'profile', + 'email', ]; /** diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php index 8eaae002f6..3b09839bd1 100644 --- a/src/Appwrite/Auth/Validator/PersonalData.php +++ b/src/Appwrite/Auth/Validator/PersonalData.php @@ -33,7 +33,7 @@ class PersonalData extends Password /** * Is valid. * - * @param mixed $value + * @param mixed $password * * @return bool */ diff --git a/src/Appwrite/Detector/Detector.php b/src/Appwrite/Detector/Detector.php index 61286835f5..73259673dd 100644 --- a/src/Appwrite/Detector/Detector.php +++ b/src/Appwrite/Detector/Detector.php @@ -6,14 +6,8 @@ use DeviceDetector\DeviceDetector; class Detector { - /** - * @param string - */ protected $userAgent = ''; - /** - * @param DeviceDetector - */ protected $detctor; /** diff --git a/src/Appwrite/Docker/Compose.php b/src/Appwrite/Docker/Compose.php index 241e281ed8..9ea6420d2d 100644 --- a/src/Appwrite/Docker/Compose.php +++ b/src/Appwrite/Docker/Compose.php @@ -12,9 +12,6 @@ class Compose */ protected $compose = []; - /** - * @var string $data - */ public function __construct(string $data) { $this->compose = yaml_parse($data); diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php index a3f9c91253..87699aaeba 100644 --- a/src/Appwrite/Docker/Compose/Service.php +++ b/src/Appwrite/Docker/Compose/Service.php @@ -11,9 +11,6 @@ class Service */ protected $service = []; - /** - * @var string $path - */ public function __construct(array $service) { $this->service = $service; diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index 3bf6fb2d50..af5e4f11e2 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -9,9 +9,6 @@ class Env */ protected $vars = []; - /** - * @var string $data - */ public function __construct(string $data) { $data = explode("\n", $data); diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index bf6339f8a0..ae75e3924f 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -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(); diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index 2d12aa542c..38d7a27c11 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -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 diff --git a/src/Appwrite/Event/Message/Usage.php b/src/Appwrite/Event/Message/Usage.php index 776188d5b5..c72bc8ae2a 100644 --- a/src/Appwrite/Event/Message/Usage.php +++ b/src/Appwrite/Event/Message/Usage.php @@ -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'] ?? []), diff --git a/src/Appwrite/Event/Messaging.php b/src/Appwrite/Event/Messaging.php index 8c13185e0b..9895d52ec2 100644 --- a/src/Appwrite/Event/Messaging.php +++ b/src/Appwrite/Event/Messaging.php @@ -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 diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index f7c76d3800..58a21b5517 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -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'; @@ -330,6 +333,8 @@ class Exception extends \Exception /** Platform */ public const string PLATFORM_NOT_FOUND = 'platform_not_found'; + public const string PLATFORM_METHOD_UNSUPPORTED = 'platform_method_unsupported'; + public const string PLATFORM_ALREADY_EXISTS = 'platform_already_exists'; /** GraphqQL */ public const string GRAPHQL_NO_QUERY = 'graphql_no_query'; diff --git a/src/Appwrite/Functions/EventProcessor.php b/src/Appwrite/Functions/EventProcessor.php index e9c3b7241a..d41ee56c5d 100644 --- a/src/Appwrite/Functions/EventProcessor.php +++ b/src/Appwrite/Functions/EventProcessor.php @@ -8,6 +8,18 @@ use Utopia\Database\Query; class EventProcessor { + /** + * @param array $events + * @return array + */ + 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); } } diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 484cafb0ab..689724d9f1 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -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); diff --git a/src/Appwrite/GraphQL/Schema.php b/src/Appwrite/GraphQL/Schema.php index 57115ff027..4ff96fb635 100644 --- a/src/Appwrite/GraphQL/Schema.php +++ b/src/Appwrite/GraphQL/Schema.php @@ -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; } diff --git a/src/Appwrite/GraphQL/Types.php b/src/Appwrite/GraphQL/Types.php index 279cac2068..3d5979dc18 100644 --- a/src/Appwrite/GraphQL/Types.php +++ b/src/Appwrite/GraphQL/Types.php @@ -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); diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 037f80bcf7..53474b855a 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -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); diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a4f73eb5f2..e481eebf6e 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -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 $queries + * @return \Generator + * @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. * diff --git a/src/Appwrite/Migration/Version/V15.php b/src/Appwrite/Migration/Version/V15.php index 66037660c0..eefa84ec22 100644 --- a/src/Appwrite/Migration/Version/V15.php +++ b/src/Appwrite/Migration/Version/V15.php @@ -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', - ]); + ]) ?: ''; } } diff --git a/src/Appwrite/Migration/Version/V20.php b/src/Appwrite/Migration/Version/V20.php index 3c13815949..e3458c815e 100644 --- a/src/Appwrite/Migration/Version/V20.php +++ b/src/Appwrite/Migration/Version/V20.php @@ -452,7 +452,7 @@ class V20 extends Migration Query::equal('period', ['1d']), ]); - $value = $query ?? 0; + $value = $query; $this->createInfMetric($to, $value); } diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php index dc9ce9d196..a2d9d7907b 100644 --- a/src/Appwrite/Migration/Version/V24.php +++ b/src/Appwrite/Migration/Version/V24.php @@ -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'); diff --git a/src/Appwrite/Network/Cors.php b/src/Appwrite/Network/Cors.php index 9fc47c5808..593c82ab24 100644 --- a/src/Appwrite/Network/Cors.php +++ b/src/Appwrite/Network/Cors.php @@ -48,7 +48,7 @@ final class Cors /** * Build CORS headers for a given request origin. * - * @return array + * @return array */ public function headers(string $origin): array { diff --git a/src/Appwrite/Network/Platform.php b/src/Appwrite/Network/Platform.php index 1cf5de91d1..23fb087010 100644 --- a/src/Appwrite/Network/Platform.php +++ b/src/Appwrite/Network/Platform.php @@ -6,20 +6,10 @@ class Platform { public const TYPE_UNKNOWN = 'unknown'; public const TYPE_WEB = 'web'; - public const TYPE_FLUTTER_IOS = 'flutter-ios'; - public const TYPE_FLUTTER_ANDROID = 'flutter-android'; - public const TYPE_FLUTTER_MACOS = 'flutter-macos'; - public const TYPE_FLUTTER_WINDOWS = 'flutter-windows'; - public const TYPE_FLUTTER_LINUX = 'flutter-linux'; - public const TYPE_FLUTTER_WEB = 'flutter-web'; - public const TYPE_APPLE_IOS = 'apple-ios'; - public const TYPE_APPLE_MACOS = 'apple-macos'; - public const TYPE_APPLE_WATCHOS = 'apple-watchos'; - public const TYPE_APPLE_TVOS = 'apple-tvos'; + public const TYPE_APPLE = 'apple'; public const TYPE_ANDROID = 'android'; - public const TYPE_UNITY = 'unity'; - public const TYPE_REACT_NATIVE_IOS = 'react-native-ios'; - public const TYPE_REACT_NATIVE_ANDROID = 'react-native-android'; + public const TYPE_WINDOWS = 'windows'; + public const TYPE_LINUX = 'linux'; public const TYPE_SCHEME = 'scheme'; public const SCHEME_HTTP = 'http'; @@ -57,6 +47,36 @@ class Platform self::SCHEME_TAURI => 'Web (Tauri)', ]; + /** + * Map deprecated platform types to their new consolidated types. + * + * The 1.9.x refactor consolidated ~15 platform types into 5 new ones. + * Existing platforms in the database may still have old type values. + * + * @param string $type + * @return string The mapped type, or the original if not deprecated. + */ + public static function mapDeprecatedType(string $type): string + { + $mapping = [ + 'flutter-web' => self::TYPE_WEB, + 'unity' => self::TYPE_WEB, + 'flutter-ios' => self::TYPE_APPLE, + 'flutter-macos' => self::TYPE_APPLE, + 'apple-ios' => self::TYPE_APPLE, + 'apple-macos' => self::TYPE_APPLE, + 'apple-watchos' => self::TYPE_APPLE, + 'apple-tvos' => self::TYPE_APPLE, + 'react-native-ios' => self::TYPE_APPLE, + 'flutter-android' => self::TYPE_ANDROID, + 'react-native-android' => self::TYPE_ANDROID, + 'flutter-windows' => self::TYPE_WINDOWS, + 'flutter-linux' => self::TYPE_LINUX, + ]; + + return $mapping[$type] ?? $type; + } + /** * Get user-friendly platform name from a scheme. * @@ -77,25 +97,28 @@ class Platform $key = strtolower($platform['key'] ?? ''); switch ($type) { + case 'flutter-web': + case 'unity': case self::TYPE_WEB: - case self::TYPE_FLUTTER_WEB: if (!empty($hostname)) { $hostnames[] = $hostname; } break; - case self::TYPE_FLUTTER_IOS: - case self::TYPE_FLUTTER_ANDROID: - case self::TYPE_FLUTTER_MACOS: - case self::TYPE_FLUTTER_WINDOWS: - case self::TYPE_FLUTTER_LINUX: + case 'flutter-android': + case 'react-native-android': case self::TYPE_ANDROID: - case self::TYPE_APPLE_IOS: - case self::TYPE_APPLE_MACOS: - case self::TYPE_APPLE_WATCHOS: - case self::TYPE_APPLE_TVOS: - case self::TYPE_REACT_NATIVE_IOS: - case self::TYPE_REACT_NATIVE_ANDROID: - case self::TYPE_UNITY: + case 'flutter-windows': + case self::TYPE_WINDOWS: + case 'flutter-linux': + case self::TYPE_LINUX: + case 'flutter-ios': + case 'flutter-macos': + case 'apple-ios': + case 'apple-macos': + case 'apple-watchos': + case 'apple-tvos': + case 'react-native-ios': + case self::TYPE_APPLE: if (!empty($key)) { $hostnames[] = $key; } @@ -120,38 +143,38 @@ class Platform $schemes[] = $scheme; } break; + case 'flutter-web': + case 'unity': case self::TYPE_WEB: - case self::TYPE_FLUTTER_WEB: $schemes[] = self::SCHEME_HTTP; $schemes[] = self::SCHEME_HTTPS; break; - case self::TYPE_FLUTTER_IOS: - case self::TYPE_APPLE_IOS: - case self::TYPE_REACT_NATIVE_IOS: - $schemes[] = self::SCHEME_IOS; - break; - case self::TYPE_FLUTTER_ANDROID: + case 'flutter-android': + case 'react-native-android': case self::TYPE_ANDROID: - case self::TYPE_REACT_NATIVE_ANDROID: $schemes[] = self::SCHEME_ANDROID; break; - case self::TYPE_FLUTTER_MACOS: - case self::TYPE_APPLE_MACOS: + case 'flutter-ios': + case 'flutter-macos': + case 'apple-ios': + case 'apple-macos': + case 'apple-watchos': + case 'apple-tvos': + case 'react-native-ios': + case self::TYPE_APPLE: + $schemes[] = self::SCHEME_WATCHOS; $schemes[] = self::SCHEME_MACOS; + $schemes[] = self::SCHEME_TVOS; + $schemes[] = self::SCHEME_IOS; break; - case self::TYPE_FLUTTER_WINDOWS: - case self::TYPE_UNITY: + case 'flutter-windows': + case self::TYPE_WINDOWS: $schemes[] = self::SCHEME_WINDOWS; break; - case self::TYPE_FLUTTER_LINUX: + case 'flutter-linux': + case self::TYPE_LINUX: $schemes[] = self::SCHEME_LINUX; break; - case self::TYPE_APPLE_WATCHOS: - $schemes[] = self::SCHEME_WATCHOS; - break; - case self::TYPE_APPLE_TVOS: - $schemes[] = self::SCHEME_TVOS; - break; default: break; } diff --git a/src/Appwrite/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php index 1965a3c858..787feb0904 100644 --- a/src/Appwrite/OpenSSL/OpenSSL.php +++ b/src/Appwrite/OpenSSL/OpenSSL.php @@ -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); } diff --git a/src/Appwrite/Platform/Action.php b/src/Appwrite/Platform/Action.php index 01ac92a45c..0aa0da7149 100644 --- a/src/Appwrite/Platform/Action.php +++ b/src/Appwrite/Platform/Action.php @@ -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 diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index e29222a703..8aaaf621bb 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -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); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php index ce308aa906..dea356eaaf 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/View.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -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)) { diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 17edfaac72..99ec9e65d2 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -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); }); diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Action.php b/src/Appwrite/Platform/Modules/Avatars/Http/Action.php index bf7d01764f..d6b58b33dd 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Action.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Action.php @@ -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 diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index 0a4d652d0e..b6cc408dde 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -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 diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php index eb56ddf0b2..77ff9dd8ce 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php @@ -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 diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php index 8278a43ea3..c73d20fb0c 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php @@ -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; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php index b2417871ed..7893a70753 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php @@ -7,9 +7,13 @@ use Appwrite\Platform\Action as AppwriteAction; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Operator; +use Utopia\Database\Query; class Action extends AppwriteAction { + public const LIST_CACHE_FIELD_DOCUMENTS = 'documents'; + public const LIST_CACHE_FIELD_TOTAL = 'total'; + private string $context = DATABASE_TYPE_LEGACY; public function getDatabaseType(): string @@ -17,7 +21,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 +32,8 @@ class Action extends AppwriteAction if (\str_contains($path, '/vectorsdb')) { $this->context = DATABASE_TYPE_VECTORSDB; } - return parent::setHttpPath($path); + parent::setHttpPath($path); + return $this; } /** @@ -100,4 +105,67 @@ class Action extends AppwriteAction return $data; } + + /** + * Stable Redis key for a collection's cached list responses. + * + * All variations (schema × roles × queries) for a single collection live as + * fields inside this one Redis hash, so purging every cached entry for a + * collection is a single O(1) DEL regardless of how many variations have + * been cached. + */ + protected function getListCacheKey(Database $dbForProject, string $collectionId): string + { + return \sprintf( + '%s-cache:%s:%s:%s:collection:%s', + $dbForProject->getCacheName(), + $dbForProject->getAdapter()->getHostname(), + $dbForProject->getNamespace(), + $dbForProject->getTenant(), + $collectionId, + ); + } + + /** + * Hash field for a single variation of a cached list response. + * + * Scoped by the collection schema (attributes + indexes), the caller's + * authorization roles, the exact query set, and the field type — so users + * with different permissions never share entries. + * + * @param Document $collection Collection document (for schema hash) + * @param array $roles Caller authorization roles + * @param array $queries Queries for this list call + * @param string $type LIST_CACHE_FIELD_DOCUMENTS or LIST_CACHE_FIELD_TOTAL + */ + protected function getListCacheField(Document $collection, array $roles, array $queries, string $type): string + { + $schemaHash = \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + + $serialized = \array_map( + static fn ($query) => $query instanceof Query ? $query->toArray() : $query, + $queries, + ); + + return \sprintf( + '%s:%s:%s:%s', + $schemaHash, + \md5(\json_encode($roles)), + \md5(\json_encode($serialized)), + $type, + ); + } + + /** + * Purge every cached list response for a collection. + * + * One DEL on the collection's Redis hash, clearing all variations at once. + */ + protected function purgeListCache(Database $dbForProject, string $collectionId): bool + { + return $dbForProject->getCache()->purge($this->getListCacheKey($dbForProject, $collectionId)); + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 2f541936a8..4afab449c0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -3,34 +3,29 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections; use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Platform\Action as UtopiaAction; -use Utopia\Platform\Scope\HTTP; -abstract class Action extends UtopiaAction +abstract class Action extends DatabasesAction { /** * The current API context (either 'table' or 'collection'). */ private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; - /** * Get the response model used in the SDK and HTTP responses. */ abstract protected function getResponseModel(): string; - public function setHttpPath(string $path): UtopiaAction + public function setHttpPath(string $path): self { if (\str_contains($path, '/tablesdb')) { $this->context = TABLES; - $this->databaseType = TABLESDB; - } elseif (\str_contains($path, '/vectorsdb')) { - $this->databaseType = VECTORSDB; } - return parent::setHttpPath($path); + parent::setHttpPath($path); + return $this; } /** @@ -41,14 +36,6 @@ abstract class Action extends UtopiaAction return $this->context; } - /** - * Get the current API database type. - */ - protected function getDatabaseType(): string - { - return $this->databaseType; - } - /** * Get the key used in event parameters (e.g., 'collectionId' or 'tableId'). */ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 0bd4a2e080..91dd9c603c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -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); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index 8d31e19753..e0464f7e52 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -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()) { @@ -206,6 +207,8 @@ class Decrement extends Action ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); + $response->dynamic($document, $this->getResponseModel()); + $queueForEvents ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collectionId) @@ -215,7 +218,5 @@ class Decrement extends Action ->setContext('database', $database) ->setContext($this->getCollectionsEventsContext(), $collection) ->setPayload($response->getPayload(), sensitive: $relationships); - - $response->dynamic($document, $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 9de5f83154..de090f9882 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -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()) { @@ -206,6 +207,8 @@ class Increment extends Action ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); + $response->dynamic($document, $this->getResponseModel()); + $queueForEvents ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collectionId) @@ -215,7 +218,5 @@ class Increment extends Action ->setContext('database', $database) ->setContext($this->getCollectionsEventsContext(), $collection) ->setPayload($response->getPayload(), sensitive: $relationships); - - $response->dynamic($document, $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 08c3b047be..38c84c4ae1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -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, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 9931109c49..ecc5b152ec 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -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]); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index d84eb75a0f..b48df136ee 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -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)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php index 4588e3666b..8aaac5fcb4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php @@ -131,6 +131,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index f006ad7f59..b86d934ffb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -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) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index 0dfc64f392..ef89b80e97 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -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)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index bc9d30c6f2..c35eebaea2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -72,7 +72,7 @@ class XList extends Action ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -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)) { @@ -127,84 +127,59 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + // When there are no select queries, relationship loading is skipped on the + // underlying find() to avoid pulling related documents the caller did not ask for. + $find = $hasSelects + ? fn () => $dbForDatabases->find($collectionTableId, $queries) + : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; - } elseif (! empty($selectQueries)) { + } elseif ((int)$ttl > 0) { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - if ((int)$ttl > 0) { - $serializedQueries = []; - foreach ($queries as $query) { - $serializedQueries[] = $query instanceof Query ? $query->toArray() : $query; - } - - $hostname = $dbForProject->getAdapter()->getHostname(); - $roles = $dbForProject->getAuthorization()->getRoles(); - $schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', []))); - $cacheKeyBase = \sprintf( - '%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s', - $dbForProject->getCacheName(), - $hostname ?? '', - $dbForProject->getNamespace(), - $dbForProject->getTenant(), - $collectionId, - $schemaHash, - \md5(\json_encode($roles)), - \md5(\json_encode($serializedQueries)) - ); - - $documentsCacheKey = $cacheKeyBase . ':documents'; - $totalCacheKey = $cacheKeyBase . ':total'; - - $documentsCacheHit = $totalDocumentsCacheHit = false; - - $cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl); - - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $dbForDatabases->find($collectionTableId, $queries); - - // Convert Document objects to arrays for caching - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - $dbForProject->getCache()->save($documentsCacheKey, $documentsArray); - } - - if ($includeTotal) { - $cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl); - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; - $totalDocumentsCacheHit = true; - } else { - $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($totalCacheKey, $total); - } - } else { - $total = 0; - } - - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + $documentsCacheHit = false; + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; } else { - // has selects, allow relationship on documents - $documents = $dbForDatabases->find($collectionTableId, $queries); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $find(); + + // Convert Document objects to arrays for caching + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); } + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { - // has no selects, disable relationship loading on documents - /* @type Document[] $documents */ - $documents = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + $documents = $find(); $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php index 19b1cbbde1..cc082532f9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php @@ -123,6 +123,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'] ?? null, 'time' => $log['time'] ?? null, 'osCode' => $os['osCode'] ?? null, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php index 5d9d425d71..800df6d044 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php @@ -68,6 +68,7 @@ class Update extends Action ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -76,7 +77,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, bool $purge, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -99,8 +100,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, @@ -119,6 +118,10 @@ class Update extends Action ->setParam('databaseId', $databaseId) ->setParam($this->getEventsParamKey(), $collection->getId()); + if ($purge) { + $this->purgeListCache($dbForProject, $collectionId); + } + $this->addRowBytesInfo($collection, $dbForProject); $response->dynamic($collection, $this->getResponseModel()); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index 3585bc4477..3d07c65250 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -61,16 +61,16 @@ class Create extends Action $databaseKeys = System::getEnv('_APP_DATABASE_DOCUMENTSDB_KEYS', ''); $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'); - $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); - $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); + $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))); + $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''))); break; case VECTORSDB: $databases = Config::getParam('pools-vectorsdb', []); $databaseKeys = System::getEnv('_APP_DATABASE_VECTORSDB_KEYS', ''); $databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'); - $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); - $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); + $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))); + $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''))); break; default: // legacy/tablesdb @@ -108,7 +108,7 @@ class Create extends Action if ($index !== false) { $selectedDsn = $databases[$index]; } else { - if (!empty($dsn)) { + if (!empty($dsn) && !empty($databaseSharedTables)) { $beforeFilter = \array_values($databases); if ($isSharedTablesV1) { $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1)); @@ -118,7 +118,10 @@ class Create extends Action $databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables)); } } - $selectedDsn = !empty($databases) ? $databases[array_rand($databases)] : ''; + if (empty($databases)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, "No {$databasetype} database pool available for the current shared-tables mode"); + } + $selectedDsn = $databases[array_rand($databases)]; } if (\in_array($selectedDsn, $databaseSharedTables)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php index 0c0f5f1273..1ed7e6a63f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -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'], @@ -110,6 +113,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], @@ -121,9 +125,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']); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index f3edf010d4..91bc1a3ccf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -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; } /** diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php index 26457cc4a0..f06feccdee 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php @@ -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', diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 5e88eee500..c4d51e6c64 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -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', diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php index de3acdc96a..6d986fc6b1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php @@ -68,6 +68,7 @@ class Decrement extends DecrementDocumentAttribute ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php index 8664bb09ec..09def76941 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php @@ -68,6 +68,7 @@ class Increment extends IncrementDocumentAttribute ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php index 86749f8a3d..0253c287aa 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php @@ -71,6 +71,7 @@ class Delete extends DocumentDelete ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php index 4dd1f6f6b3..47d352bf98 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php @@ -59,6 +59,7 @@ class Get extends DocumentGet ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php index b5c612c155..9e79bb5464 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php @@ -70,6 +70,7 @@ class Update extends DocumentUpdate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php index 3aee3ebcb1..dc3ce34605 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php @@ -58,7 +58,7 @@ class Create extends IndexCreate ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).', false, ['dbForProject']) ->param('key', null, fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject']) - ->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE, Database::INDEX_SPATIAL]), 'Index type.') + ->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE]), 'Index type.') ->param('attributes', null, fn (Database $dbForProject) => new ArrayList(new Key(true, $dbForProject->getAdapter()->getMaxUIDLength()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.', false, ['dbForProject']) ->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true) ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php index 052970fec4..3acedc0379 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php @@ -58,6 +58,7 @@ class Update extends CollectionUpdate ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php index bc15d440d1..963af2f43e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php @@ -55,6 +55,7 @@ class Create extends OperationsCreate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php index 6754179425..81822df208 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -97,6 +97,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'], @@ -104,6 +107,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], @@ -115,9 +119,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']); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php index 2670cc00aa..ea1bfa163d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php @@ -70,6 +70,7 @@ class Decrement extends DecrementDocumentAttribute ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php index ca6589aa3a..2f8be876d7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php @@ -70,6 +70,7 @@ class Increment extends IncrementDocumentAttribute ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php index addc87f610..06aee2cb30 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php @@ -73,6 +73,7 @@ class Delete extends DocumentDelete ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php index 48a24e9ec4..65ae238a1a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php @@ -61,6 +61,7 @@ class Get extends DocumentGet ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php index 99599bd169..93ec5d3b58 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php @@ -71,6 +71,7 @@ class Update extends DocumentUpdate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index ca83b10aae..91c62aea05 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -57,7 +57,7 @@ class XList extends DocumentXList ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php index 88b16d57f0..d10380a0e8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php @@ -60,6 +60,7 @@ class Update extends CollectionUpdate ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('rowSecurity', false, new Boolean(true), 'Enables configuring permissions for individual rows. A user needs one of row or table-level permissions to access a row. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this table as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php index 818ed70cea..b00b75f270 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php @@ -56,6 +56,7 @@ class Create extends OperationsCreate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index b85a8b30b4..58433c7deb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -130,9 +130,26 @@ class Create extends CollectionAction $indexes[] = new Document($index); } try { - // passing null in creates only creates the metadata collection - if (!$dbForDatabases->exists(null, Database::METADATA)) { - $dbForDatabases->create(); + // Bootstrap the database metadata without a separate existence + // check to avoid races when multiple first collections are created + // concurrently for the same VectorsDB database. + for ($attempt = 0; $attempt < 5; $attempt++) { + try { + $dbForDatabases->create(); + break; + } catch (DuplicateException) { + break; + } catch (\Throwable $e) { + if ($dbForDatabases->exists(null, Database::METADATA)) { + break; + } + + if ($attempt === 4) { + throw $e; + } + + \usleep(100_000); + } } $dbForDatabases->createCollection( id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php index eca6049970..e81e34e1e5 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php @@ -71,6 +71,7 @@ class Delete extends DocumentDelete ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php index 2a7090a01e..73f7a55026 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php @@ -59,6 +59,7 @@ class Get extends DocumentGet ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php index 2624cc82e4..8a8b9d89ae 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php @@ -70,6 +70,7 @@ class Update extends DocumentUpdate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php index 830c0c3fe1..27283cda49 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php @@ -55,6 +55,7 @@ class Create extends OperationsCreate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php index e9c665ea4b..50c901e4c8 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php @@ -88,16 +88,11 @@ class Get extends Action throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); } - switch ($type) { - case 'output': - $path = $deployment->getAttribute('buildPath', ''); - $device = $deviceForBuilds; - break; - case 'source': - $path = $deployment->getAttribute('sourcePath', ''); - $device = $deviceForFunctions; - break; - } + [$path, $device] = match ($type) { + 'output' => [$deployment->getAttribute('buildPath', ''), $deviceForBuilds], + 'source' => [$deployment->getAttribute('sourcePath', ''), $deviceForFunctions], + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid deployment download type.'), + }; if (!$device->exists($path)) { throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index ee33abe9e1..37292ce984 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -119,7 +119,7 @@ class Create extends Base Document $project, Database $dbForProject, Database $dbForPlatform, - Document $user, + User $user, Event $queueForEvents, Context $usage, Func $queueForFunctions, @@ -171,8 +171,8 @@ class Create extends Base /* @var Document $function */ $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); @@ -213,7 +213,10 @@ class Create extends Base $current = new Document(); foreach ($sessions as $session) { - /** @var Utopia\Database\Document $session */ + if (!$session instanceof Document) { + continue; + } + if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // Find most recent active session for user ID and JWT headers $current = $session; } @@ -237,11 +240,11 @@ class Create extends Base ]); $executionId = ID::unique(); - $headers['x-appwrite-execution-id'] = $executionId ?? ''; + $headers['x-appwrite-execution-id'] = $executionId; $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = 'http'; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; - $headers['x-appwrite-user-jwt'] = $jwt ?? ''; + $headers['x-appwrite-user-id'] = $user->getId(); + $headers['x-appwrite-user-jwt'] = $jwt; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; @@ -350,16 +353,18 @@ class Create extends Base } } - $this->enqueueDeletes( - $project, - $function->getSequence(), - $executionsRetentionCount, + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { $queueForDeletes - ); + ->setProject($project) + ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } - return $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($execution, Response::MODEL_EXECUTION); + $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); + $response->dynamic($execution, Response::MODEL_EXECUTION); + return; } $durationStart = \microtime(true); @@ -370,7 +375,7 @@ class Create extends Base 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'] ?? '' ]); @@ -537,32 +542,18 @@ class Create extends Base } } - $this->enqueueDeletes( - $project, - $function->getSequence(), - $executionsRetentionCount, + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { $queueForDeletes - ); + ->setProject($project) + ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); } - private function enqueueDeletes( - Document $project, - string $resourceId, - int $executionsRetentionCount, - DeleteEvent $queueForDeletes - ): void { - /* cleanup */ - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $queueForDeletes - ->setProject($project) - ->setResource($resourceId) - ->setResourceType(RESOURCE_TYPE_FUNCTIONS) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index 70912cf58c..aec9d56543 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -53,6 +53,7 @@ class Get extends Base ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -61,12 +62,13 @@ class Get extends Base string $executionId, Response $response, Database $dbForProject, - Authorization $authorization + Authorization $authorization, + User $user ) { $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index dcc3f6ee9c..b12980b222 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -62,6 +62,7 @@ class XList extends Base ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -71,12 +72,13 @@ class XList extends Base bool $includeTotal, Response $response, Database $dbForProject, - Authorization $authorization + Authorization $authorization, + User $user ) { $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index d281c64414..8d4ad5d403 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -424,8 +424,8 @@ class Create extends Base /** Trigger Realtime Events */ $queueForRealtime - ->from($ruleCreate) ->setSubscribers(['console', $project->getId()]) + ->from($ruleCreate) ->trigger(); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index a627bae9dd..71fc99a30e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -170,8 +170,6 @@ class Update extends Base $runtime = $function->getAttribute('runtime'); } - $enabled ??= $function->getAttribute('enabled', true); - $repositoryId = $function->getAttribute('repositoryId', ''); $repositoryInternalId = $function->getAttribute('repositoryInternalId', ''); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index bcedeee764..c6c4a0b38c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -450,7 +450,7 @@ class Builds extends Action $providerCommitHash = \trim($stdout); - $deployment->setAttribute('providerCommitHash', $providerCommitHash ?? ''); + $deployment->setAttribute('providerCommitHash', $providerCommitHash); $deployment->setAttribute('providerCommitAuthorUrl', APP_VCS_GITHUB_URL); $deployment->setAttribute('providerCommitAuthor', APP_VCS_GITHUB_USERNAME); $deployment->setAttribute('providerCommitMessage', "Create '" . $resource->getAttribute('name', '') . "' function"); @@ -862,7 +862,7 @@ class Builds extends Action if (\str_contains($logs, '{APPWRITE_DETECTION_SEPARATOR_START}')) { [$logsBefore, $detectionLogsStart] = \explode('{APPWRITE_DETECTION_SEPARATOR_START}', $logs, 2); [$detectionLogs, $logsAfter] = \explode('{APPWRITE_DETECTION_SEPARATOR_END}', $detectionLogsStart, 2); - $logs = ($logsBefore ?? '') . ($logsAfter ?? ''); + $logs = $logsBefore . $logsAfter; } $deployment->setAttribute('buildLogs', $logs); @@ -1203,6 +1203,8 @@ class Builds extends Action protected function sendUsage(Document $resource, Document $deployment, Document $project, Context $usage, UsagePublisher $publisherForUsage): void { $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + $cpus = (int) ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT); + $memory = (int) ($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT); switch ($deployment->getAttribute('status')) { case 'ready': @@ -1364,6 +1366,8 @@ class Builds extends Action Realtime $queueForRealtime, array $platform ): void { + $deployment = new Document(); + try { if ($resource->getAttribute('providerSilentMode', false) === true) { return; @@ -1444,7 +1448,7 @@ class Builds extends Action $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; $previewUrl = match ($resource->getCollection()) { 'functions' => '', - 'sites' => ! empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', + 'sites' => !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', default => throw new \Exception('Invalid resource type') }; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php new file mode 100644 index 0000000000..59d2c1db49 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -0,0 +1,119 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('Create project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array|null $scopes + */ + public function action( + string $keyId, + string $name, + ?array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId; + + $key = new Document([ + '$id' => $keyId, + '$permissions' => [], + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), + 'resourceType' => 'projects', + 'name' => $name, + 'scopes' => $scopes ?? [], + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->createDocument('keys', $key)); + } catch (DuplicateException) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php new file mode 100644 index 0000000000..c5da673e22 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Delete project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].delete') + ->label('audits.event', 'project.key.delete') + ->label('audits.resource', 'project.key/{request.keyId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'deleteKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Event $queueForEvents, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('keys', $key->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php new file mode 100644 index 0000000000..e43c669e4f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Get project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'getKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php new file mode 100644 index 0000000000..8759faacc1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -0,0 +1,111 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Update project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].update') + ->label('audits.event', 'project.key.update') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'updateKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array|null $scopes + */ + public function action( + string $keyId, + string $name, + ?array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + $updates = new Document([ + 'name' => $name, + 'scopes' => $scopes ?? [], + 'expire' => $expire, + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php new file mode 100644 index 0000000000..d243e6f2c3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -0,0 +1,127 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('List project keys') + ->groups(['api', 'project']) + ->label('scope', 'keys.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'listKeys', + description: <<param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Backwards compatibility + if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { + $queries[] = Query::limit(5000); + } + + $queries[] = Query::equal('resourceType', ['projects']); + $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $keyId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('keys', [ + Query::equal('$id', [$keyId]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $keys = $authorization->skip(fn () => $dbForPlatform->find('keys', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT)) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'keys' => $keys, + 'total' => $total, + ]), Response::MODEL_KEY_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php similarity index 60% rename from src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index de11bb0091..24d1c48cf1 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -1,20 +1,15 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) - ->setHttpPath('/v1/projects/:projectId/labels') + ->setHttpPath('/v1/project/labels') + ->httpAlias('/v1/projects/:projectId/labels') ->desc('Update project labels') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'labels.*.update') + ->label('audits.event', 'project.labels.update') + ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', + namespace: 'project', + group: null, name: 'updateLabels', description: <<param('projectId', '', new UID(), 'Project unique ID.') ->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), APP_LIMIT_ARRAY_LABELS_SIZE), 'Array of project labels. Replaces the previous labels. Maximum of ' . APP_LIMIT_ARRAY_LABELS_SIZE . ' labels are allowed, each up to 36 alphanumeric characters long.') ->inject('response') ->inject('dbForPlatform') + ->inject('project') ->callback($this->action(...)); } @@ -67,17 +60,11 @@ class Update extends Action * @param array $labels */ public function action( - string $projectId, array $labels, Response $response, - Database $dbForPlatform + Database $dbForPlatform, + Document $project ): void { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - $labels = (array) \array_values(\array_unique($labels)); $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php new file mode 100644 index 0000000000..accc6d5b35 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/android') + ->desc('Create project Android platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createAndroidPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('applicationId', '', new Text(256), 'Android application ID. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $applicationId, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_ANDROID, + 'name' => $name, + 'key' => $applicationId, + 'hostname' => '', + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_ANDROID); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php new file mode 100644 index 0000000000..3ff958e814 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/android/:platformId') + ->desc('Update project Android platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateAndroidPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('applicationId', '', new Text(256), 'Android application ID. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $applicationId, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_ANDROID) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $applicationId, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_ANDROID); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php new file mode 100644 index 0000000000..0843bf9a0c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/apple') + ->desc('Create project Apple platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createApplePlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('bundleIdentifier', '', new Text(256), 'Apple bundle identifier. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $bundleIdentifier, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_APPLE, + 'name' => $name, + 'key' => $bundleIdentifier, + 'hostname' => '', + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_APPLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php new file mode 100644 index 0000000000..0295075f19 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/apple/:platformId') + ->desc('Update project Apple platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateApplePlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('bundleIdentifier', '', new Text(256), 'Apple bundle identifier. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $bundleIdentifier, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_APPLE) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $bundleIdentifier, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_APPLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php new file mode 100644 index 0000000000..4b58766751 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/platforms/:platformId') + ->httpAlias('/v1/projects/:projectId/platforms/:platformId') + ->desc('Delete project platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].delete') + ->label('audits.event', 'project.platform.delete') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'deletePlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Event $queueForEvents, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('platforms', $platform->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php new file mode 100644 index 0000000000..4bbbc2fc8c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php @@ -0,0 +1,92 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/platforms/:platformId') + ->httpAlias('/v1/projects/:projectId/platforms/:platformId') + ->desc('Get project platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'getPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + Document $project + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + $type = Platform::mapDeprecatedType($platform->getAttribute('type')); + $platform->setAttribute('type', $type); + + $model = match($type) { + Platform::TYPE_WEB => Response::MODEL_PLATFORM_WEB, + Platform::TYPE_APPLE => Response::MODEL_PLATFORM_APPLE, + Platform::TYPE_ANDROID => Response::MODEL_PLATFORM_ANDROID, + Platform::TYPE_WINDOWS => Response::MODEL_PLATFORM_WINDOWS, + Platform::TYPE_LINUX => Response::MODEL_PLATFORM_LINUX, + default => Response::MODEL_PLATFORM_WEB // Backwards compatibility + }; + + $response->dynamic($platform, $model); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php new file mode 100644 index 0000000000..472b41cace --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/linux') + ->desc('Create project Linux platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createLinuxPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('packageName', '', new Text(256), 'Linux package name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageName, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_LINUX, + 'name' => $name, + 'key' => $packageName, + 'hostname' => '', // Web platform attribute + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_LINUX); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php new file mode 100644 index 0000000000..9c1f715c33 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/linux/:platformId') + ->desc('Update project Linux platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateLinuxPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('packageName', '', new Text(256), 'Linux package name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_LINUX) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $packageName, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_LINUX); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php new file mode 100644 index 0000000000..6794901c47 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -0,0 +1,173 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/web') + ->httpAlias('/v1/projects/:projectId/platforms') + ->desc('Create project web platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createWebPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('key', '', new Text(256), 'Deprecated: Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility + ->param('type', '', new Text(256), 'Deprecated: Platform type. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility + ->inject('request') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $hostname, + ?string $key, // For backwards compatibility + ?string $type, // For backwards compatibility + Request $request, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $key = $key ?? ''; // App platform attribute, backwards compatibility + $type = $type ?? ''; // App platform attribute, backwards compatibility + + // Backwards compatibility + // Used to have: type, name, key, hostname + if (!empty($type)) { + // Validate deprecated type, and rename to new type + $deprecatedTypeMapping = [ + // Web + 'web' => Platform::TYPE_WEB, + 'flutter-web' => Platform::TYPE_WEB, + 'unity' => Platform::TYPE_WEB, // Was not officially supported anyway + + // Apple + 'flutter-macos' => Platform::TYPE_APPLE, + 'flutter-ios' => Platform::TYPE_APPLE, + 'react-native-ios' => Platform::TYPE_APPLE, + 'apple-ios' => Platform::TYPE_APPLE, + 'apple-macos' => Platform::TYPE_APPLE, + 'apple-watchos' => Platform::TYPE_APPLE, + 'apple-tvos' => Platform::TYPE_APPLE, + + // Android + 'flutter-android' => Platform::TYPE_ANDROID, + 'android' => Platform::TYPE_ANDROID, + 'react-native-android' => Platform::TYPE_ANDROID, + + 'flutter-linux' => Platform::TYPE_LINUX, + 'flutter-windows' => Platform::TYPE_WINDOWS, + ]; + + $typeValidator = new WhiteList(\array_keys($deprecatedTypeMapping)); + if (!$typeValidator->isValid($request->getParam('type', ''))) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "type" is invalid: ' . $typeValidator->getDescription()); + } + + $type = $deprecatedTypeMapping[$request->getParam('type', '')] ?? ''; + } + + if (!empty($key)) { + // Validate deprecated app id (key) + $keyValidator = new Text(256); + if (!$keyValidator->isValid($key)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "key" is invalid: ' . $keyValidator->getDescription()); + } + } + + if (empty($key) && empty($type)) { + // Modern request, validate hostname + if (empty($hostname)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + } + } + + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => $type ?: Platform::TYPE_WEB, // Preserve type for backwards compatibility + 'name' => $name, + 'key' => $key, + 'hostname' => $hostname + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_WEB); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php new file mode 100644 index 0000000000..1e1f1b5ac1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -0,0 +1,151 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/web/:platformId') + ->httpAlias('/v1/projects/:projectId/platforms/:platformId') + ->desc('Update project web platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateWebPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $hostname, + ?string $key, // For backwards compatibility + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $key = $key ?? ''; // App platform attribute, backwards compatibility + + // Backwards compatibility + // Used to have: type, name, key, hostname + if (!empty($key)) { + // Validate deprecated app id (key) + $keyValidator = new Text(256); + if (!$keyValidator->isValid($key)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "key" is invalid: ' . $keyValidator->getDescription()); + } + } + + // One day, ideally, we ensure hostname is not empty + // But for backwards compatibility backend must threat it as optional for now + + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + // Wrapped in if, for backwards compatibility + if (!empty($hostname)) { + $supportedTypes = [ + Platform::TYPE_WEB, + // Backwards compatibility + 'flutter-web', + 'unity', + 'flutter-macos', + 'flutter-ios', + 'react-native-ios', + 'apple-ios', + 'apple-macos', + 'apple-watchos', + 'apple-tvos', + 'flutter-android', + 'react-native-android', + 'flutter-windows', + 'flutter-linux', + ]; + if (!in_array($platform->getAttribute('type', ''), $supportedTypes)) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + } + + $updates = new Document([ + 'name' => $name, + ]); + + // Wrapped in if, for backwards compatibility + if (!empty($hostname)) { + $updates->setAttribute('hostname', $hostname); + } + + // Backwards compatibility + if (!empty($key)) { + $updates->setAttribute('key', $key); + } + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_WEB); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php new file mode 100644 index 0000000000..58be45d03b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/windows') + ->desc('Create project Windows platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createWindowsPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('packageIdentifierName', '', new Text(256), 'Windows package identifier name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageIdentifierName, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_WINDOWS, + 'name' => $name, + 'key' => $packageIdentifierName, + 'hostname' => '', + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_WINDOWS); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php new file mode 100644 index 0000000000..5cfb6ee7ea --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/windows/:platformId') + ->desc('Update project Windows platform') + ->groups(['api', 'project']) + ->label('scope', 'platforms.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateWindowsPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') + ->param('packageIdentifierName', '', new Text(256), 'Windows package identifier name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageIdentifierName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_WINDOWS) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $packageIdentifierName, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_WINDOWS); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php new file mode 100644 index 0000000000..3913f08e75 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php @@ -0,0 +1,130 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/platforms') + ->httpAlias('/v1/projects/:projectId/platforms') + ->desc('List project platforms') + ->groups(['api', 'project']) + ->label('scope', 'platforms.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'listPlatforms', + description: <<param('queries', [], new Platforms(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Platforms::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + foreach ($queries as $query) { + if (\in_array($query->getAttribute(), ['bundleIdentifier', 'applicationId', 'packageIdentifierName', 'packageName'])) { + $query->setAttribute('key'); + } + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $platformId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('platforms', [ + Query::equal('$id', [$platformId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Platform '{$platformId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $platforms = $authorization->skip(fn () => $dbForPlatform->find('platforms', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('platforms', $filterQueries, APP_LIMIT_COUNT)) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + foreach ($platforms as $platform) { + $platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type'))); + } + + $response->dynamic(new Document([ + 'platforms' => $platforms, + 'total' => $total, + ]), Response::MODEL_PLATFORM_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index acc39bb68d..8dbc720045 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -19,7 +18,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -class Create extends Base +class Create extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index ac47ec3dbb..2b0ae8feb1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -16,7 +15,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Delete extends Base +class Delete extends Action { use HTTP; @@ -35,7 +34,7 @@ class Delete extends Base ->label('scope', 'project.write') ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'project.variable.delete') - ->label('audits.resource', 'project.variable/{response.$id}') + ->label('audits.resource', 'project.variable/{request.variableId}') ->label('sdk', new Method( namespace: 'project', group: 'variables', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php index 6de51dacaf..af14148c92 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -13,7 +12,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Get extends Base +class Get extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 61a943b618..988a7c0849 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -19,7 +18,7 @@ use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; use Utopia\Validator\Text; -class Update extends Base +class Update extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php index cd11fe68c6..bd391ea3b4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -19,7 +18,7 @@ use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; -class XList extends Base +class XList extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 949fb2bcd9..9fd8366097 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,25 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; +use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Update as UpdateApplePlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Delete as DeletePlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Get as GetPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Linux\Create as CreateLinuxPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Linux\Update as UpdateLinuxPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Create as CreateWebPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as UpdateWebPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -20,10 +39,35 @@ class Http extends Service $this->addAction(Init::getName(), new Init()); // Project + $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); $this->addAction(GetVariable::getName(), new GetVariable()); $this->addAction(DeleteVariable::getName(), new DeleteVariable()); $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + + // Keys + $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(ListKeys::getName(), new ListKeys()); + $this->addAction(GetKey::getName(), new GetKey()); + $this->addAction(DeleteKey::getName(), new DeleteKey()); + $this->addAction(UpdateKey::getName(), new UpdateKey()); + + // Platforms + $this->addAction(DeletePlatform::getName(), new DeletePlatform()); + $this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform()); + $this->addAction(UpdateApplePlatform::getName(), new UpdateApplePlatform()); + $this->addAction(UpdateAndroidPlatform::getName(), new UpdateAndroidPlatform()); + $this->addAction(UpdateWindowsPlatform::getName(), new UpdateWindowsPlatform()); + $this->addAction(UpdateLinuxPlatform::getName(), new UpdateLinuxPlatform()); + $this->addAction(CreateWebPlatform::getName(), new CreateWebPlatform()); + $this->addAction(CreateApplePlatform::getName(), new CreateApplePlatform()); + $this->addAction(CreateAndroidPlatform::getName(), new CreateAndroidPlatform()); + $this->addAction(CreateWindowsPlatform::getName(), new CreateWindowsPlatform()); + $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); + $this->addAction(GetPlatform::getName(), new GetPlatform()); + $this->addAction(ListPlatforms::getName(), new ListPlatforms()); } } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index eb71e5a02f..9070962e7d 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -114,6 +114,9 @@ class Create extends Action 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false, + 'disposableEmails' => false, + 'canonicalEmails' => false, + 'freeEmails' => false, 'mockNumbers' => [], 'sessionAlerts' => false, 'membershipsUserName' => false, diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 8b0d6f87c8..8275e664d5 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -8,7 +8,6 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; -use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; @@ -31,7 +30,6 @@ class Http extends Service $this->addAction(CreateProject::getName(), new CreateProject()); $this->addAction(UpdateProject::getName(), new UpdateProject()); $this->addAction(ListProjects::getName(), new ListProjects()); - $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); $this->addAction(CreateSchedule::getName(), new CreateSchedule()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php index fb83cb34c5..8a0d341132 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php @@ -83,7 +83,8 @@ class Update extends Action // If rule is already verified or in certificate generation state, don't queue for verification again if ($rule->getAttribute('status') === RULE_STATUS_VERIFIED || $rule->getAttribute('status') === RULE_STATUS_CERTIFICATE_GENERATING) { - return $response->dynamic($rule, Response::MODEL_PROXY_RULE); + $response->dynamic($rule, Response::MODEL_PROXY_RULE); + return; } try { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php index 600135e152..339d059365 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php @@ -87,16 +87,11 @@ class Get extends Action throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); } - switch ($type) { - case 'output': - $path = $deployment->getAttribute('buildPath', ''); - $device = $deviceForBuilds; - break; - case 'source': - $path = $deployment->getAttribute('sourcePath', ''); - $device = $deviceForSites; - break; - } + [$path, $device] = match ($type) { + 'output' => [$deployment->getAttribute('buildPath', ''), $deviceForBuilds], + 'source' => [$deployment->getAttribute('sourcePath', ''), $deviceForSites], + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid deployment download type.'), + }; if (!$device->exists($path)) { throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 6510e505ae..dd9bedffb5 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -172,8 +172,6 @@ class Update extends Base $framework = $site->getAttribute('framework'); } - $enabled ??= $site->getAttribute('enabled', true); - $repositoryId = $site->getAttribute('repositoryId', ''); $repositoryInternalId = $site->getAttribute('repositoryInternalId', ''); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 827dbc8dd9..c5f4f3dccd 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -103,7 +103,7 @@ class Create extends Action Request $request, Response $response, Database $dbForProject, - Document $user, + User $user, Event $queueForEvents, string $mode, Device $deviceForFiles, @@ -112,8 +112,8 @@ class Create extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -286,6 +286,8 @@ class Create extends Action $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption $data = ''; + $iv = ''; + $tag = null; // Compression $algorithm = $bucket->getAttribute('compression', Compression::NONE); if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index 9bbc400168..5b44c61d18 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -66,6 +66,7 @@ class Delete extends Action ->inject('deviceForFiles') ->inject('queueForDeletes') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -78,11 +79,12 @@ class Delete extends Action Device $deviceForFiles, DeleteEvent $queueForDeletes, Authorization $authorization, + User $user, ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php index 042ae76565..c876004319 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php @@ -70,6 +70,7 @@ class Get extends Action ->inject('resourceToken') ->inject('deviceForFiles') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -84,12 +85,13 @@ class Get extends Action Document $resourceToken, Device $deviceForFiles, Authorization $authorization, + User $user, ) { /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php index caaab29efc..c9ce5796eb 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php @@ -51,6 +51,7 @@ class Get extends Action ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -59,12 +60,13 @@ class Get extends Action string $fileId, Response $response, Database $dbForProject, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 63a72fc683..a5e48be478 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -92,6 +92,7 @@ class Get extends Action ->inject('deviceForLocal') ->inject('project') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -117,7 +118,8 @@ class Get extends Action Device $deviceForFiles, Device $deviceForLocal, Document $project, - Authorization $authorization + Authorization $authorization, + User $user ) { if (!\extension_loaded('imagick')) { @@ -127,8 +129,8 @@ class Get extends Action /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -271,7 +273,7 @@ class Get extends Action $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; //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()); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php index c475c53d24..5b3fd02370 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php @@ -52,6 +52,7 @@ class Get extends Action ->inject('mode') ->inject('deviceForFiles') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -66,7 +67,8 @@ class Get extends Action Document $project, string $mode, Device $deviceForFiles, - Authorization $authorization + Authorization $authorization, + User $user ) { $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); @@ -88,8 +90,8 @@ class Get extends Action $disposition = $decoded['disposition'] ?? 'inline'; $dbForProject = $isInternal ? $dbForPlatform : $dbForProject; - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 57856c1564..8e69468170 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -64,6 +64,7 @@ class Update extends Action ->inject('dbForProject') ->inject('queueForEvents') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -75,12 +76,13 @@ class Update extends Action Response $response, Database $dbForProject, Event $queueForEvents, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -108,7 +110,7 @@ class Update extends Action // Users can only manage their own roles, API keys and Admin users can manage any $roles = $authorization->getRoles(); - if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) { + if (!$user->isApp($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) { foreach (Database::PERMISSIONS as $type) { foreach ($permissions as $permission) { $permission = Permission::parse($permission); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php index cba6c2fa13..b2f00da6d2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php @@ -71,6 +71,7 @@ class Get extends Action ->inject('resourceToken') ->inject('deviceForFiles') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -84,13 +85,14 @@ class Get extends Action string $mode, Document $resourceToken, Device $deviceForFiles, - Authorization $authorization + Authorization $authorization, + User $user ) { /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php index 6de360ae0e..945c4bfd7c 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php @@ -63,6 +63,7 @@ class XList extends Action ->inject('dbForProject') ->inject('mode') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -74,12 +75,13 @@ class XList extends Action Response $response, Database $dbForProject, string $mode, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php index e4827a6354..406f8e4f49 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php @@ -99,12 +99,8 @@ class Update extends Action $permissions ??= $bucket->getPermissions(); $maximumFileSize ??= $bucket->getAttribute('maximumFileSize', (int) System::getEnv('_APP_STORAGE_LIMIT', 0)); - $allowedFileExtensions ??= $bucket->getAttribute('allowedFileExtensions', []); - $enabled ??= $bucket->getAttribute('enabled', true); $encryption ??= $bucket->getAttribute('encryption', true); - $antivirus ??= $bucket->getAttribute('antivirus', true); $compression ??= $bucket->getAttribute('compression', Compression::NONE); - $transformations ??= $bucket->getAttribute('transformations', true); // Map aggregate permissions into the multiple permissions they represent. $permissions = Permission::aggregate($permissions); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php index 486807d5f9..acdb6defc7 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php @@ -103,6 +103,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 0632aea3dd..5edc69f445 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -98,10 +98,12 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken) + public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken) { - $isAppUser = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + $invitee = new Document(); + $hash = ''; if (empty($url)) { if (! $isAppUser && ! $isPrivilegedUser) { @@ -145,9 +147,6 @@ class Create extends Action } } elseif (! empty($phone)) { $invitee = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]); - if (! $invitee->isEmpty() && ! empty($email) && $invitee->getAttribute('email', '') !== $email) { - throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given phone and email doesn\'t match', 409); - } } if ($invitee->isEmpty()) { // Create new user if no user with same email found @@ -169,14 +168,41 @@ class Create extends Action throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); } + $emailMetadata = [ + 'emailCanonical' => null, + 'emailIsCanonical' => null, + 'emailIsCorporate' => null, + 'emailIsDisposable' => null, + 'emailIsFree' => null, + ]; + try { - $userId = ID::unique(); - $hash = $proofForPassword->hash($proofForPassword->generate()); - $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); + } + + $hash = $proofForPassword->hash($proofForPassword->generate()); + $userId = ID::unique(); $userDocument = new Document([ @@ -209,11 +235,11 @@ class Create extends Action 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $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'], ]); try { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 9bfbd8528e..f3fd9a4bb9 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -52,10 +52,11 @@ class Get extends Action ->inject('project') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization) + public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user) { $team = $dbForProject->getDocument('teams', $teamId); @@ -76,25 +77,25 @@ class Get extends Action ]; $roles = $authorization->getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; }, $membershipsPrivacy); - $user = !empty(array_filter($membershipsPrivacy)) + $memberUser = !empty(array_filter($membershipsPrivacy)) ? $dbForProject->getDocument('users', $membership->getAttribute('userId')) : new Document(); if ($membershipsPrivacy['mfa']) { - $mfa = $user->getAttribute('mfa', false); + $mfa = $memberUser->getAttribute('mfa', false); if ($mfa) { - $totp = TOTP::getAuthenticatorFromUser($user); + $totp = TOTP::getAuthenticatorFromUser($memberUser); $totpEnabled = $totp && $totp->getAttribute('verified', false); - $emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false); - $phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false); + $emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false); + $phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false); if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) { $mfa = false; @@ -105,11 +106,11 @@ class Get extends Action } if ($membershipsPrivacy['userName']) { - $membership->setAttribute('userName', $user->getAttribute('name')); + $membership->setAttribute('userName', $memberUser->getAttribute('name')); } if ($membershipsPrivacy['userEmail']) { - $membership->setAttribute('userEmail', $user->getAttribute('email')); + $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } $membership->setAttribute('teamName', $team->getAttribute('name')); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php index 46b6c3cacf..28bfa769ee 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php @@ -74,10 +74,12 @@ class Update extends Action ->inject('queueForEvents') ->inject('store') ->inject('proofForToken') + ->inject('domainVerification') + ->inject('cookieDomain') ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) + public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, bool $domainVerification, ?string $cookieDomain) { $protocol = $request->getProtocol(); @@ -162,7 +164,7 @@ class Update extends Action ->setProperty('secret', $secret) ->encode(); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } @@ -172,7 +174,7 @@ class Update extends Action value: $encoded, expire: (new \DateTime($expire))->getTimestamp(), path: '/', - domain: Config::getParam('cookieDomain'), + domain: $cookieDomain, secure: ('https' === $protocol), httponly: true ) @@ -181,7 +183,7 @@ class Update extends Action value: $encoded, expire: (new \DateTime($expire))->getTimestamp(), path: '/', - domain: Config::getParam('cookieDomain'), + domain: $cookieDomain, secure: ('https' === $protocol), httponly: true, sameSite: Config::getParam('cookieSamesite') diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php index a935055163..540dc8a871 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php @@ -66,7 +66,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents) + public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents) { $team = $dbForProject->getDocument('teams', $teamId); if ($team->isEmpty()) { @@ -83,8 +83,8 @@ class Update extends Action throw new Exception(Exception::USER_NOT_FOUND); } - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - $isAppUser = User::isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); $isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner'); if ($project->getId() === 'console') { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index ba59f48b43..364f92e1c5 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -61,10 +61,11 @@ class XList extends Action ->inject('project') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization) + public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user) { $team = $dbForProject->getDocument('teams', $teamId); @@ -129,26 +130,26 @@ class XList extends Action ]; $roles = $authorization->getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; }, $membershipsPrivacy); $memberships = array_map(function ($membership) use ($dbForProject, $team, $membershipsPrivacy) { - $user = !empty(array_filter($membershipsPrivacy)) + $memberUser = !empty(array_filter($membershipsPrivacy)) ? $dbForProject->getDocument('users', $membership->getAttribute('userId')) : new Document(); if ($membershipsPrivacy['mfa']) { - $mfa = $user->getAttribute('mfa', false); + $mfa = $memberUser->getAttribute('mfa', false); if ($mfa) { - $totp = TOTP::getAuthenticatorFromUser($user); + $totp = TOTP::getAuthenticatorFromUser($memberUser); $totpEnabled = $totp && $totp->getAttribute('verified', false); - $emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false); - $phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false); + $emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false); + $phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false); if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) { $mfa = false; @@ -159,11 +160,11 @@ class XList extends Action } if ($membershipsPrivacy['userName']) { - $membership->setAttribute('userName', $user->getAttribute('name')); + $membership->setAttribute('userName', $memberUser->getAttribute('name')); } if ($membershipsPrivacy['userEmail']) { - $membership->setAttribute('userEmail', $user->getAttribute('email')); + $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } $membership->setAttribute('teamName', $team->getAttribute('name')); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php index ae20017e76..0d20a58b6b 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php @@ -68,10 +68,10 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents) + public function action(string $teamId, string $name, array $roles, Response $response, User $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents) { - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - $isAppUser = User::isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); $teamId = $teamId == 'unique()' ? ID::unique() : $teamId; diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php index 5f1bd55788..934074d3c2 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php @@ -11,12 +11,12 @@ use Utopia\Platform\Action as UtopiaAction; class Action extends UtopiaAction { - protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, string $bucketId, string $fileId): array + protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, User $user, string $bucketId, string $fileId): array { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php index 10257d3603..1c2bd692ef 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php @@ -8,6 +8,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Utopia\Auth\Proofs\Token; use Utopia\Database\Database; @@ -64,19 +65,20 @@ class Create extends Action ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File unique ID.', false, ['dbForProject']) ->param('expire', null, new Nullable(new DatetimeValidator(requireDateInFuture: true)), 'Token expiry date', true) ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $bucketId, string $fileId, ?string $expire, Response $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void + public function action(string $bucketId, string $fileId, ?string $expire, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void { /** * @var Document $bucket * @var Document $file */ - ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId); + ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId); $fileSecurity = $bucket->getAttribute('fileSecurity', false); $bucketPermission = $authorization->isValid(new Input(Database::PERMISSION_UPDATE, $bucket->getUpdate())); diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php index 3d7be9bf81..5a93a06f26 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php @@ -7,6 +7,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\Queries\FileTokens; use Appwrite\Utopia\Response; use Utopia\Database\Database; @@ -57,14 +58,15 @@ class XList extends Action ->param('queries', [], new FileTokens(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', FileTokens::ALLOWED_ATTRIBUTES), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Authorization $authorization) + public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, User $user, Database $dbForProject, Authorization $authorization) { - ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId); + ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId); $queries = Query::parseQueries($queries); $queries[] = Query::equal('resourceType', [TOKENS_RESOURCE_TYPE_FILES]); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php index 4a34ffd36a..8b320535e9 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php @@ -110,10 +110,7 @@ class Update extends Action $providerRepositoryId = $repository->getAttribute('providerRepositoryId'); try { - $providerRepositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; - if (empty($providerRepositoryName)) { - throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); - } + $providerRepositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index 914bcaa93e..69da270e19 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -65,6 +65,7 @@ class Get extends Action } $state = \json_decode($state, true); + $redirectFailure = $state['failure'] ?? ''; $projectId = $state['projectId'] ?? ''; $project = $dbForPlatform->getDocument('projects', $projectId); @@ -74,10 +75,11 @@ class Get extends Action if (!empty($redirectFailure)) { $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; - return $response + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + return; } throw new Exception(Exception::PROJECT_NOT_FOUND, $error); @@ -165,10 +167,11 @@ class Get extends Action if (!empty($redirectFailure)) { $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; - return $response + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + return; } throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 04e2dbd406..6e1db12c28 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -49,6 +49,8 @@ trait Deployment ) { $errors = []; foreach ($repositories as $repository) { + $logBase = 'vcs.github.event.repo.unknown'; + try { $repositoryId = $repository->getId(); $projectId = $repository->getAttribute('projectId'); @@ -70,6 +72,8 @@ trait Deployment throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project'); } + $this->beforeCreateGitDeployment($project, $repository, $dbForPlatform, $authorization); + try { $dsn = new DSN($project->getAttribute('database')); $databaseName = $dsn->getHost(); @@ -105,18 +109,11 @@ trait Deployment $owner = $github->getOwnerName($providerInstallationId) ?? ''; try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; - if (empty($repositoryName)) { - throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); - } + $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - if (empty($repositoryName)) { - throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); - } - $isAuthorized = !$external; if (!$isAuthorized && !empty($providerPullRequestId)) { @@ -289,10 +286,7 @@ trait Deployment $providerRepositoryId = $repository->getAttribute('providerRepositoryId'); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; - if (empty($repositoryName)) { - throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); - } + $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -499,7 +493,7 @@ trait Deployment $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId)); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : ''; + $previewUrl = !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : ''; if (!empty($previewUrl)) { $comment = new Comment($platform); @@ -522,10 +516,7 @@ trait Deployment $providerRepositoryId = $repository->getAttribute('providerRepositoryId'); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; - if (empty($repositoryName)) { - throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); - } + $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -561,6 +552,10 @@ trait Deployment } } + protected function beforeCreateGitDeployment(Document $project, Document $repository, Database $dbForPlatform, Authorization $authorization): void + { + } + protected function getBuildQueueName(Document $project, Database $dbForPlatform, Authorization $authorization): string { return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php index a2fa44c613..e3dbcfa0e9 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -83,7 +83,7 @@ class Create extends Action default => null, }; - return $response->json($parsedPayload); + $response->json($parsedPayload); } protected function preprocessEvent(Request $request) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index bada6d98bb..5dd5c6dcfa 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -82,12 +82,11 @@ class Create extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_DETECTION_RUNTIME, + model: [ + Response::MODEL_DETECTION_RUNTIME, + Response::MODEL_DETECTION_FRAMEWORK, + ], ), - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DETECTION_FRAMEWORK, - ) ] )) ->param('installationId', '', new Text(256), 'Installation Id') diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index ca2c812901..d5b2b48175 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -86,12 +86,11 @@ class XList extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + model: [ + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + ], ), - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, - ) ] )) ->param('installationId', '', new Text(256), 'Installation Id') diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index 91daf33b2b..3c716202af 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Event\Event as QueueEvent; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -25,7 +24,7 @@ use Utopia\Validator\Multiple; use Utopia\Validator\Text; use Utopia\Validator\URL; -class Create extends Base +class Create extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php index 7730e9fc2c..cd05b6210c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -18,7 +17,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Delete extends Base +class Delete extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index 52ac455fc9..ebe6fa7bcb 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,7 +15,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Get extends Base +class Get extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php index 9b2612863f..51c5bfbaf9 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -17,7 +16,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Update extends Base +class Update extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index a1387c356c..968c15dae2 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Event\Event as QueueEvent; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -24,7 +23,7 @@ use Utopia\Validator\Multiple; use Utopia\Validator\Text; use Utopia\Validator\URL; -class Update extends Base +class Update extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index fae95d7c5d..2a4c4f9e59 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -20,7 +19,7 @@ use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; -class XList extends Base +class XList extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Tasks/Doctor.php b/src/Appwrite/Platform/Tasks/Doctor.php index 9a9c2fdf73..3eeaa95e64 100644 --- a/src/Appwrite/Platform/Tasks/Doctor.php +++ b/src/Appwrite/Platform/Tasks/Doctor.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Tasks; use Appwrite\ClamAV\Network; use Appwrite\PubSub\Adapter\Pool as PubSubPool; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Cache\Adapter\Pool as CachePool; use Utopia\Config\Config; use Utopia\Console; @@ -13,6 +12,8 @@ use Utopia\Domains\Domain; use Utopia\DSN\DSN; use Utopia\Http\Http; use Utopia\Logger\Logger; +use Utopia\Messaging\Adapter\Email as EmailAdapter; +use Utopia\Messaging\Messages\Email as EmailMessage; use Utopia\Platform\Action; use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; @@ -124,7 +125,7 @@ class Doctor extends Action $providerConfig = System::getEnv('_APP_LOGGING_CONFIG', ''); try { - $loggingProvider = new DSN($providerConfig ?? ''); + $loggingProvider = new DSN($providerConfig); $providerName = $loggingProvider->getScheme(); @@ -212,15 +213,18 @@ class Doctor extends Action } try { - /* @var PHPMailer $mail */ - $mail = $register->get('smtp'); + /** @var EmailAdapter $smtp */ + $smtp = $register->get('smtp'); - $mail->addAddress('demo@example.com', 'Example.com'); - $mail->Subject = 'Test SMTP Connection'; - $mail->Body = 'Hello World'; - $mail->AltBody = 'Hello World'; + $emailMessage = new EmailMessage( + to: ['demo@example.com'], + subject: 'Test SMTP Connection', + content: 'Hello World', + fromName: \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')), + fromEmail: System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), + ); - $mail->send(); + $smtp->send($emailMessage); Console::success('🟢 ' . str_pad("SMTP", 50, '.') . 'connected'); } catch (\Throwable) { Console::error('🔴 ' . str_pad("SMTP", 47, '.') . 'disconnected'); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index eab6babc66..dd7bed0137 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -7,6 +7,7 @@ use Appwrite\Docker\Env; use Appwrite\Platform\Installer\Runtime\State; use Appwrite\Platform\Installer\Server as InstallerServer; use Appwrite\Utopia\View; +use Swoole\Coroutine; use Utopia\Auth\Proofs\Password; use Utopia\Auth\Proofs\Token; use Utopia\Config\Config; @@ -35,6 +36,7 @@ class Install extends Action private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; protected bool $isUpgrade = false; + protected bool $migrate = false; protected string $hostPath = ''; protected ?bool $isLocalInstall = null; protected ?array $installerConfig = null; @@ -211,20 +213,21 @@ class Install extends Action } // If interactive and web mode enabled, start web server - if ($interactive === 'Y' && Console::isInteractive()) { + // Skip the web installer when explicit CLI params are provided + if ($interactive === 'Y' && Console::isInteractive() && !$this->hasExplicitCliParams()) { Console::success('Starting web installer...'); Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT); Console::info('Press Ctrl+C to cancel installation'); $detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null; - $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade, $detectedDb); + $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade || $existingInstallation, $detectedDb); return; } // Fall back to CLI mode $enableAssistant = false; $assistantExistsInOldCompose = false; - if ($existingInstallation && isset($compose)) { + if ($existingInstallation) { try { $assistantService = $compose->getService('appwrite-assistant'); $assistantExistsInOldCompose = $assistantService !== null; @@ -321,7 +324,7 @@ class Install extends Action $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade; $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets); - $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade, migrate: $this->migrate); } @@ -510,7 +513,9 @@ class Install extends Action ?callable $progress = null, ?string $resumeFromStep = null, bool $isUpgrade = false, - array $account = [] + array $account = [], + ?callable $onComplete = null, + bool $migrate = false, ): void { $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, false); @@ -633,8 +638,34 @@ class Install extends Action $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); } - // Track installs - $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + if ($isUpgrade && $migrate) { + // Allow the containers-completed SSE event to flush + // before blocking on migration exec + usleep(200_000); + $currentStep = InstallerServer::STEP_MIGRATION; + $this->runDatabaseMigration($progress, $isLocalInstall); + } elseif ($isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_MIGRATION, InstallerServer::STATUS_COMPLETED, messageOverride: 'Migration skipped'); + } + + // Signal completion before tracking so the SSE stream + // finishes and the frontend can redirect immediately. + if ($onComplete) { + try { + $onComplete(); + } catch (\Throwable) { + } + } + + // Run tracking in a coroutine when inside a Swoole + // request so it doesn't block the worker. + if (Coroutine::getCid() !== -1) { + go(function () use ($input, $isUpgrade, $version, $account) { + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + }); + } else { + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + } if ($isCLI) { Console::success('Appwrite installed successfully'); @@ -726,6 +757,46 @@ class Install extends Action } } + private function runDatabaseMigration(?callable $progress, bool $isLocalInstall): void + { + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_IN_PROGRESS, + messageOverride: 'Running database migration...' + ); + + // Allow the SSE chunk to flush before the blocking exec + usleep(100_000); + + // Static command — no user input involved + $command = $isLocalInstall + ? 'docker compose exec appwrite migrate 2>&1' + : 'docker exec appwrite migrate 2>&1'; + + $output = []; + \exec($command, $output, $exit); + + if ($exit !== 0) { + $message = trim(implode("\n", $output)); + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_ERROR, + details: ['output' => $message], + messageOverride: 'Migration failed: ' . ($message ?: 'exit code ' . $exit) + ); + throw new \RuntimeException('Database migration failed', 0, $message !== '' ? new \RuntimeException($message) : null); + } + + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_COMPLETED, + messageOverride: 'Database migration completed' + ); + } + private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void { if ($this->isLocalInstall()) { @@ -753,7 +824,7 @@ class Install extends Action $name = $account['name'] ?? 'Admin'; $email = $account['email'] ?? 'admin@selfhosted.local'; - $hostIp = gethostbyname($domain); + $hostIp = @gethostbyname($domain); $payload = [ 'action' => $type, @@ -767,7 +838,7 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, - 'hostIp' => $hostIp !== $domain ? $hostIp : null, + 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null, 'os' => php_uname('s') . ' ' . php_uname('r'), 'arch' => php_uname('m'), 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, @@ -778,6 +849,8 @@ class Install extends Action try { $client = new Client(); $client + ->setConnectTimeout(5000) + ->setTimeout(5000) ->addHeader('Content-Type', 'application/json') ->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload); } catch (\Throwable) { @@ -1261,6 +1334,22 @@ class Install extends Action $this->hostPath = $this->getInstallerHostPath(); } + /** + * Check if any installer-specific CLI params were explicitly passed. + * When params like --database or --http-port are provided, the user + * intends to run in CLI mode rather than launching the web installer. + */ + private function hasExplicitCliParams(): bool + { + $argv = $_SERVER['argv'] ?? []; + foreach ($argv as $arg) { + if (\str_starts_with($arg, '--')) { + return true; + } + } + return false; + } + /** * Detect the database adapter from a pre-1.9.0 compose file by * checking which DB service exists or reading _APP_DB_HOST. diff --git a/src/Appwrite/Platform/Tasks/Migrate.php b/src/Appwrite/Platform/Tasks/Migrate.php index b952808998..6f9f92435a 100644 --- a/src/Appwrite/Platform/Tasks/Migrate.php +++ b/src/Appwrite/Platform/Tasks/Migrate.php @@ -9,7 +9,6 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Validator\Authorization; -use Utopia\Http\Http; use Utopia\Platform\Action; use Utopia\Registry\Registry; use Utopia\Validator\Text; @@ -32,6 +31,7 @@ class Migrate extends Action ->inject('getProjectDB') ->inject('register') ->inject('authorization') + ->inject('console') ->callback($this->action(...)); } @@ -48,7 +48,8 @@ class Migrate extends Action Database $dbForPlatform, callable $getProjectDB, Registry $register, - Authorization $authorization + Authorization $authorization, + Document $console ): void { if (!\array_key_exists($version, Migration::$versions)) { @@ -125,8 +126,6 @@ class Migrate extends Action Console::log('Migrated ' . ++$count . '/' . $total . ' projects...'); }); - $console = (new Http('UTC'))->getResource('console'); - try { $migration ->setProject($console, $getProjectDB($console), $dbForPlatform, $authorization, $getProjectDB); diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index a7a5f0278f..4725f4095f 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -14,7 +14,6 @@ use Appwrite\SDK\Language\Flutter; use Appwrite\SDK\Language\Go; use Appwrite\SDK\Language\GraphQL; use Appwrite\SDK\Language\Kotlin; -use Appwrite\SDK\Language\Markdown; use Appwrite\SDK\Language\Node; use Appwrite\SDK\Language\PHP; use Appwrite\SDK\Language\Python; @@ -25,6 +24,7 @@ use Appwrite\SDK\Language\Rust; use Appwrite\SDK\Language\Swift; use Appwrite\SDK\Language\Web; use Appwrite\SDK\SDK; +use Appwrite\Spec\StaticSpec; use Appwrite\Spec\Swagger2; use CzProject\GitPhp\Git; use Utopia\Agents\Adapters\OpenAI; @@ -50,7 +50,10 @@ class SDKs extends Action public static function getPlatforms(): array { - return Specs::getPlatforms(); + return [ + ...Specs::getPlatforms(), + APP_SDK_PLATFORM_STATIC, + ]; } protected function getSdkConfigPath(): string @@ -99,11 +102,14 @@ class SDKs extends Action } else { $sdks = explode(',', $sdks); } - $version ??= Console::confirm('Choose an Appwrite version'); $createRelease = ($release === 'yes'); $commitRelease = ($commit === 'yes'); + if ($createRelease && $examplesOnly) { + throw new \Exception('Cannot use --release=yes with --mode=examples'); + } + if (! $createRelease && ! $examplesOnly) { $git ??= Console::confirm('Should we use git push? (yes/no)'); $git = ($git === 'yes'); @@ -115,30 +121,34 @@ class SDKs extends Action $prUrls = []; } - if (! \in_array($version, [ - '0.6.x', - '0.7.x', - '0.8.x', - '0.9.x', - '0.10.x', - '0.11.x', - '0.12.x', - '0.13.x', - '0.14.x', - '0.15.x', - '1.0.x', - '1.1.x', - '1.2.x', - '1.3.x', - '1.4.x', - '1.5.x', - '1.6.x', - '1.7.x', - '1.8.x', - '1.9.x', - 'latest', - ])) { - throw new \Exception('Unknown version given'); + if (! $createRelease) { + $version ??= Console::confirm('Choose an Appwrite version'); + + if (! \in_array($version, [ + '0.6.x', + '0.7.x', + '0.8.x', + '0.9.x', + '0.10.x', + '0.11.x', + '0.12.x', + '0.13.x', + '0.14.x', + '0.15.x', + '1.0.x', + '1.1.x', + '1.2.x', + '1.3.x', + '1.4.x', + '1.5.x', + '1.6.x', + '1.7.x', + '1.8.x', + '1.9.x', + 'latest', + ])) { + throw new \Exception('Unknown version given'); + } } $selectedPlatforms = ($selectedPlatform === '*' || $selectedPlatform === null) ? null : \array_map('trim', \explode(',', $selectedPlatform)); @@ -170,16 +180,140 @@ class SDKs extends Action } Console::log(''); - Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$version}) ━━━"); - Console::log(' Fetching API spec...'); - $specPath = __DIR__ . '/../../../../app/config/specs/swagger2-' . $version . '-' . $language['family'] . '.json'; + if ($createRelease && ! $examplesOnly) { + Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━"); + $changelog = $language['changelog'] ?? ''; + $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; - if (!file_exists($specPath)) { - throw new \Exception('Spec file not found: ' . $specPath . '. Please run "docker compose exec appwrite specs --version=' . $version . '" first to generate the specs.'); + $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; + $releaseVersion = $language['version']; + $releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion); + + if (empty($releaseNotes)) { + $releaseNotes = "Release version {$releaseVersion}"; + } + + $releaseTitle = $releaseVersion; + $releaseTarget = $language['repoBranch'] ?? 'main'; + + if ($repoName === '/') { + Console::warning(' Not a releasable SDK, skipping'); + + continue; + } + + // Check if release already exists + $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null'; + $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? ''); + + if (! empty($existingReleaseUrl)) { + Console::warning(" Release {$releaseVersion} already exists, skipping"); + Console::log(" {$existingReleaseUrl}"); + + continue; + } + + // Check if the latest commit on the target branch already has a release + $latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null'; + $latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? ''); + + if (! empty($latestCommitSha)) { + $latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null'; + $latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? ''); + + if (! empty($latestReleaseTag)) { + $tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null'; + $tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? ''); + + if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) { + Console::warning(" Latest commit already released ({$latestReleaseTag}), skipping"); + + continue; + } + } + } + + $previousVersion = ''; + $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1'; + $previousVersion = trim(\shell_exec($tagListCommand) ?? ''); + + $formattedNotes = "## What's Changed\n\n"; + $formattedNotes .= $releaseNotes . "\n\n"; + + if (! empty($previousVersion)) { + $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion; + } else { + $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion; + } + + if (! $commitRelease) { + Console::info(' [DRY RUN] Would create release:'); + Console::log(" Repository: {$repoName}"); + Console::log(" Version: {$releaseVersion}"); + Console::log(" Title: {$releaseTitle}"); + Console::log(" Target Branch: {$releaseTarget}"); + Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A')); + Console::log(' Release Notes:'); + Console::log(' ' . str_replace("\n", "\n ", $formattedNotes)); + } else { + Console::log(" Creating release {$releaseVersion}..."); + + $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_'); + \file_put_contents($tempNotesFile, $formattedNotes); + + $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \ + --repo ' . \escapeshellarg($repoName) . ' \ + --title ' . \escapeshellarg($releaseTitle) . ' \ + --notes-file ' . \escapeshellarg($tempNotesFile) . ' \ + --target ' . \escapeshellarg($releaseTarget) . ' \ + 2>&1'; + + $releaseOutput = []; + $releaseReturnCode = 0; + \exec($releaseCommand, $releaseOutput, $releaseReturnCode); + + \unlink($tempNotesFile); + + if ($releaseReturnCode === 0) { + // Extract release URL from output + $releaseUrl = ''; + foreach ($releaseOutput as $line) { + if (strpos($line, 'https://github.com/') !== false) { + $releaseUrl = trim($line); + break; + } + } + + Console::success(" Release {$releaseVersion} created"); + if (! empty($releaseUrl)) { + Console::log(" {$releaseUrl}"); + } + } else { + $errorMessage = implode("\n", $releaseOutput); + Console::error(" Failed to create release: " . $errorMessage); + } + } + + continue; } - $spec = file_get_contents($specPath); + Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$version}) ━━━"); + $specFormat = $language['spec'] ?? 'swagger2'; + $spec = null; + if ($specFormat === 'static') { + Console::log(' Using static SDK spec...'); + } else { + Console::log(' Fetching API spec...'); + + $specPath = __DIR__ . '/../../../../app/config/specs/swagger2-' . $version . '-' . $language['family'] . '.json'; + + if (!file_exists($specPath)) { + throw new \Exception('Spec file not found: ' . $specPath . '. Please run "docker compose exec appwrite specs --version=' . $version . '" first to generate the specs.'); + } + + $spec = file_get_contents($specPath); + } $cover = 'https://github.com/appwrite/appwrite/raw/main/public/images/github.png'; $result = \realpath(__DIR__ . '/../../../../app') . '/sdks/' . $key . '-' . $language['key']; @@ -311,10 +445,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND case 'rest': $config = new REST(); break; - case 'markdown': - $config = new Markdown(); - $config->setNPMPackage('@appwrite.io/docs'); - break; case 'agent-skills': $config = new AgentSkills(); break; @@ -325,124 +455,22 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND throw new \Exception('Language "' . $language['key'] . '" not supported'); } - if ($createRelease && ! $examplesOnly) { - $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; - $releaseVersion = $language['version']; - $releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion); - - if (empty($releaseNotes)) { - $releaseNotes = "Release version {$releaseVersion}"; - } - - $releaseTitle = $releaseVersion; - $releaseTarget = $language['repoBranch'] ?? 'main'; - - if ($repoName === '/') { - Console::warning(' Not a releasable SDK, skipping'); - - continue; - } - - // Check if release already exists - $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null'; - $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? ''); - - if (! empty($existingReleaseUrl)) { - Console::warning(" Release {$releaseVersion} already exists, skipping"); - Console::log(" {$existingReleaseUrl}"); - - continue; - } - - // Check if the latest commit on the target branch already has a release - $latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null'; - $latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? ''); - - if (! empty($latestCommitSha)) { - $latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null'; - $latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? ''); - - if (! empty($latestReleaseTag)) { - $tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null'; - $tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? ''); - - if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) { - Console::warning(" Latest commit already released ({$latestReleaseTag}), skipping"); - - continue; - } - } - } - - $previousVersion = ''; - $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1'; - $previousVersion = trim(\shell_exec($tagListCommand) ?? ''); - - $formattedNotes = "## What's Changed\n\n"; - $formattedNotes .= $releaseNotes . "\n\n"; - - if (! empty($previousVersion)) { - $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion; - } else { - $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion; - } - - if (! $commitRelease) { - Console::info(' [DRY RUN] Would create release:'); - Console::log(" Repository: {$repoName}"); - Console::log(" Version: {$releaseVersion}"); - Console::log(" Title: {$releaseTitle}"); - Console::log(" Target Branch: {$releaseTarget}"); - Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A')); - Console::log(' Release Notes:'); - Console::log(' ' . str_replace("\n", "\n ", $formattedNotes)); - } else { - Console::log(" Creating release {$releaseVersion}..."); - - $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_'); - \file_put_contents($tempNotesFile, $formattedNotes); - - $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \ - --repo ' . \escapeshellarg($repoName) . ' \ - --title ' . \escapeshellarg($releaseTitle) . ' \ - --notes-file ' . \escapeshellarg($tempNotesFile) . ' \ - --target ' . \escapeshellarg($releaseTarget) . ' \ - 2>&1'; - - $releaseOutput = []; - $releaseReturnCode = 0; - \exec($releaseCommand, $releaseOutput, $releaseReturnCode); - - \unlink($tempNotesFile); - - if ($releaseReturnCode === 0) { - // Extract release URL from output - $releaseUrl = ''; - foreach ($releaseOutput as $line) { - if (strpos($line, 'https://github.com/') !== false) { - $releaseUrl = trim($line); - break; - } - } - - Console::success(" Release {$releaseVersion} created"); - if (! empty($releaseUrl)) { - Console::log(" {$releaseUrl}"); - } - } else { - $errorMessage = implode("\n", $releaseOutput); - Console::error(" Failed to create release: " . $errorMessage); - } - } - - continue; - } - Console::log($examplesOnly ? ' Generating examples...' : ' Generating SDK...'); - $sdk = new SDK($config, new Swagger2($spec)); + $sdk = new SDK( + $config, + $specFormat === 'static' + ? new StaticSpec( + title: 'Appwrite', + description: 'Appwrite backend as a service', + version: $version, + licenseName: 'BSD-3-Clause', + licenseURL: 'https://raw.githubusercontent.com/appwrite/appwrite/master/LICENSE', + ) + : new Swagger2($spec) + ); $sdk ->setName($language['name']) @@ -483,6 +511,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND try { $sdk->generate($result); + Console::success($examplesOnly + ? " Examples generated at {$result}" + : " SDK generated at {$result}"); } catch (\Throwable $exception) { Console::error($exception->getMessage()); } @@ -535,6 +566,8 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $repoBranch = $language['repoBranch'] ?? 'main'; if ($git && !empty($gitUrl)) { + $prUrls = []; + // Generate commit message: use provided message, AI changelog, or fallback if (! empty($message)) { $commitMessage = $message; @@ -606,29 +639,15 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } catch (\Throwable) { } - // Checkout dev branch (or create if it doesn't exist) + // Create or checkout dev branch from the base branch + // This ensures dev always starts from the latest base branch, + // avoiding history divergence caused by squash merges. try { - $repo->execute('checkout', '-f', $gitBranch); + $repo->execute('checkout', '-B', $gitBranch, $repoBranch); } catch (\Throwable) { $repo->execute('checkout', '-b', $gitBranch); } - // Fetch dev branch, or push to create it on remote - try { - $repo->execute('fetch', 'origin', $gitBranch, '--quiet', '--no-tags', '--depth', '1'); - } catch (\Throwable) { - try { - $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); - } catch (\Throwable) { - } - } - - // Sync with remote dev branch - try { - $repo->execute('reset', '--hard', "origin/{$gitBranch}"); - } catch (\Throwable) { - } - // Backup .github before cleaning working tree $githubDir = $target . '/.github'; $githubBackup = \sys_get_temp_dir() . '/.github-backup-' . \getmypid(); @@ -666,7 +685,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND return true; } - $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); + $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { Console::warning(" Git push failed: " . $e->getMessage()); return false; diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 606c03bf10..2c03ad3108 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Tasks; +use Appwrite\Network\Validator\Redirect; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Specification\Format\OpenAPI3; @@ -18,6 +19,8 @@ use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\DI\Container; use Utopia\Http\Http; use Utopia\Http\Request as UtopiaRequest; use Utopia\Http\Response as UtopiaResponse; @@ -336,17 +339,30 @@ class Specs extends Action $mocks = ($mode === 'mocks'); - // Mock dependencies - Http::setResource('request', fn () => $this->getRequest()); - Http::setResource('response', fn () => $response); - Http::setResource('dbForPlatform', fn () => new Database(new MySQL(''), new Cache(new None()))); - Http::setResource('dbForProject', fn () => new Database(new MySQL(''), new Cache(new None()))); + // Mock dependencies needed by param validator injections in route definitions + $specsContainer = new Container(); + $specsContainer->set('request', fn () => $this->getRequest()); + $specsContainer->set('response', fn () => $response); + $specsContainer->set('dbForPlatform', fn () => new Database(new MySQL(''), new Cache(new None()))); + $specsContainer->set('dbForProject', fn () => new Database(new MySQL(''), new Cache(new None()))); + $specsContainer->set('redirectValidator', fn () => new Redirect([], [])); + $specsContainer->set('project', fn () => new Document([])); + $specsContainer->set('passwordsDictionary', fn () => []); + $specsContainer->set('localeCodes', fn () => \array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []))); + $specsContainer->set('plan', fn () => []); $platforms = static::getPlatforms(); $authCounts = $this->getAuthCounts(); $keys = $this->getKeys(); $generatedFiles = []; + $endpoint = System::getEnv('_APP_HOME', 'https://appwrite.io'); + $email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', 'team@appwrite.io'); + $specsDir = __DIR__ . '/../../../../app/config/specs'; + + if (!is_dir($specsDir) && !@mkdir($specsDir, 0755, true) && !is_dir($specsDir)) { + throw new Exception('Failed to create specs directory: ' . $specsDir); + } foreach ($platforms as $platform) { $routes = []; @@ -431,7 +447,7 @@ class Specs extends Action } $arguments = [ - new Http('UTC'), + $specsContainer, $services, $routes, $models, @@ -443,8 +459,6 @@ class Specs extends Action foreach (['swagger2', 'open-api3'] as $format) { $formatInstance = $this->getFormatInstance($format, $arguments); $specs = new Specification($formatInstance); - $endpoint = System::getEnv('_APP_HOME', '[HOSTNAME]'); - $email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', APP_EMAIL_TEAM); $formatInstance ->setParam('name', APP_NAME) @@ -463,36 +477,35 @@ class Specs extends Action ->setParam('docs.description', 'Full API docs, specs and tutorials') ->setParam('docs.url', $endpoint . '/docs'); - $specsDir = __DIR__ . '/../../../../app/config/specs'; + $path = $mocks + ? $specsDir . '/' . $format . '-mocks-' . $platform . '.json' + : $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json'; - if (!is_dir($specsDir)) { - if (!mkdir($specsDir, 0755, true)) { - throw new Exception('Failed to create specs directory: ' . $specsDir); - } + try { + $parsedSpecs = $specs->parse(); + } catch (\RuntimeException $e) { + throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } - if ($mocks) { - $path = $specsDir . '/' . $format . '-mocks-' . $platform . '.json'; + $encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT); - if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) { - throw new Exception('Failed to save mocks spec file: ' . $path); - } + unset($parsedSpecs); - $generatedFiles[] = realpath($path); - Console::success('Saved mocks spec file: ' . realpath($path)); - - continue; + if ($encodedSpecs === false) { + throw new Exception('Failed to encode ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . \json_last_error_msg()); } - $path = $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json'; - - if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) { - throw new Exception('Failed to save spec file: ' . $path); + if (\file_put_contents($path, $encodedSpecs) === false) { + throw new Exception('Failed to save ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . $path); } $generatedFiles[] = realpath($path); - Console::success('Saved spec file: ' . realpath($path)); + Console::success('Saved ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . realpath($path)); + + unset($encodedSpecs, $specs, $formatInstance); } + + unset($arguments, $models, $routes, $services); } if ($git === 'yes') { diff --git a/src/Appwrite/Platform/Tasks/StatsResources.php b/src/Appwrite/Platform/Tasks/StatsResources.php index 6c0e8da48b..220e377619 100644 --- a/src/Appwrite/Platform/Tasks/StatsResources.php +++ b/src/Appwrite/Platform/Tasks/StatsResources.php @@ -60,7 +60,7 @@ class StatsResources extends Action $interval = (int) System::getEnv('_APP_STATS_RESOURCES_INTERVAL', '3600'); - Console::loop(function () use ($queueForStatsResources, $dbForPlatform) { + Console::loop(function () use ($queueForStatsResources) { $last24Hours = (new \DateTime())->sub(\DateInterval::createFromDateString('24 hours')); /** diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 1d61180963..f49674896e 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -30,6 +30,7 @@ class Upgrade extends Install ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) ->param('database', 'mongodb', new Text(length: 0), 'Database to use (mongodb|mariadb|postgresql)', true) + ->param('migrate', false, new Boolean(true), 'Run database migration after upgrade', true) ->callback($this->action(...)); } @@ -40,9 +41,11 @@ class Upgrade extends Install string $image, string $interactive, bool $noStart, - string $database + string $database, + bool $migrate = false, ): void { $this->isUpgrade = true; + $this->migrate = $migrate; $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, true); diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index 55ec39026b..6bcc85bc36 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -6,7 +6,6 @@ use Exception; use Throwable; use Utopia\Console; use Utopia\Database\Document; -use Utopia\Database\Exception\Authorization; use Utopia\Database\Exception\Structure; use Utopia\Platform\Action; use Utopia\Queue\Message; @@ -51,13 +50,11 @@ class Audits extends Action /** * @param Message $message - * @param callable $getProjectDB * @param Document $project - * @param callable $getAudit + * @param callable(Document): \Utopia\Audit\Audit $getAudit * @return Commit|NoCommit * @throws Throwable * @throws \Utopia\Database\Exception - * @throws Authorization * @throws Structure */ public function action(Message $message, Document $project, callable $getAudit): Commit|NoCommit diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 716969e67a..c420444112 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -24,7 +24,6 @@ use Utopia\Database\Exception\Conflict; use Utopia\Database\Exception\Restricted; use Utopia\Database\Exception\Structure; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Platform\Action; @@ -363,7 +362,6 @@ class Deletes extends Action /** * @param Document $project * @param callable $getProjectDB - * @param Document $target * @return void * @throws Exception */ @@ -438,7 +436,6 @@ class Deletes extends Action * @param string $resource * @param string|null $resourceType * @return void - * @throws Authorization * @throws Exception */ private function deleteCacheByResource(Document $project, callable $getProjectDB, string $resource, ?string $resourceType = null): void @@ -518,7 +515,6 @@ class Deletes extends Action } /** - * @param Database $dbForPlatform * @param callable $getProjectDB * @param string $hourlyUsageRetentionDatetime * @return void @@ -586,7 +582,6 @@ class Deletes extends Action * @param Database $dbForPlatform * @param Document $document * @return void - * @throws Authorization * @throws DatabaseException * @throws Conflict * @throws Restricted @@ -623,7 +618,6 @@ class Deletes extends Action * @param Document $document * @return void * @throws Exception - * @throws Authorization * @throws DatabaseException */ protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void @@ -952,7 +946,7 @@ class Deletes extends Action // fast path, no need to list anything! $delete($dbForProject, $resourceInternalId, $resourceType); } else { - $processResource = function (string $type) use ($dbForProject, $delete, $resourceType) { + $processResource = function (string $type) use ($dbForProject, $delete) { $this->listByGroup( collection: $type, queries: [Query::select(['$id', '$sequence'])], @@ -1109,7 +1103,7 @@ class Deletes extends Action Query::equal('resourceInternalId', [$siteInternalId]), Query::equal('resourceType', ['sites']), Query::orderAsc() - ], $dbForProject, function (Document $document) use ($project, $certificates, $deviceForSites, $deviceForBuilds, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds) { + ], $dbForProject, function (Document $document) use ($deviceForSites, $deviceForBuilds, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds, &$deploymentIds) { $deploymentInternalIds[] = $document->getSequence(); $deploymentIds[] = $document->getId(); $this->deleteBuildFiles($deviceForBuilds, $document); @@ -1172,7 +1166,7 @@ class Deletes extends Action Query::equal('deploymentResourceInternalId', [$functionInternalId]), Query::equal('projectInternalId', [$project->getSequence()]), Query::orderAsc() - ], $dbForPlatform, function (Document $document) use ($project, $dbForPlatform, $certificates) { + ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { $this->deleteRule($dbForPlatform, $document, $certificates); }); @@ -1196,7 +1190,7 @@ class Deletes extends Action Query::equal('resourceInternalId', [$functionInternalId]), Query::equal('resourceType', ['functions']), Query::orderAsc() - ], $dbForProject, function (Document $document) use ($dbForPlatform, $project, $certificates, $deviceForFunctions, $deviceForBuilds, &$deploymentInternalIds) { + ], $dbForProject, function (Document $document) use ($deviceForFunctions, $deviceForBuilds, &$deploymentInternalIds) { $deploymentInternalIds[] = $document->getSequence(); $this->deleteDeploymentFiles($deviceForFunctions, $document); $this->deleteBuildFiles($deviceForBuilds, $document); @@ -1321,7 +1315,7 @@ class Deletes extends Action /** * @param Device $device - * @param Document $build + * @param Document $deployment * @return void */ private function deleteBuildFiles(Device $device, Document $deployment): void @@ -1631,9 +1625,9 @@ class Deletes extends Action try { $dbForProject->deleteDocuments('transactions', [ Query::lessThan('expiresAt', DateTime::format(new \DateTime())), - ], onNext: function (Document $transaction) use ($dbForProject, $project, &$transactionInternalIds) { + ], onNext: function (Document $transaction) use (&$transactionInternalIds) { $transactionInternalIds[] = $transaction->getSequence(); - }, onError: function (Throwable $th) use ($project) { + }, onError: function (Throwable $th) { // Swallow errors to avoid breaking the cleanup process }); } catch (Throwable $th) { @@ -1646,7 +1640,7 @@ class Deletes extends Action $dbForProject->deleteDocuments('transactionLogs', [ Query::equal('transactionInternalId', $transactionInternalIds), - ], onError: function (Throwable $th) use ($project) { + ], onError: function (Throwable $th) { // Swallow errors to avoid breaking the cleanup process }); } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 3f19abdf22..bed28dad1c 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -33,7 +33,7 @@ class Functions extends Action } /** - * @throws Exception + * @throws \Exception */ public function __construct() { @@ -256,7 +256,7 @@ class Functions extends Action * @param Document $user * @param string|null $jwt * @param string|null $event - * @throws Exception + * @throws \Exception */ private function fail( string $message, @@ -271,10 +271,10 @@ class Functions extends Action ?string $event = null, ): void { $executionId = ID::unique(); - $headers['x-appwrite-execution-id'] = $executionId ?? ''; + $headers['x-appwrite-execution-id'] = $executionId; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt ?? ''; $headersFiltered = []; @@ -458,8 +458,8 @@ class Functions extends Action if ($version === 'v2') { $vars = \array_merge($vars, [ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', - 'APPWRITE_FUNCTION_DATA' => $body ?? '', - 'APPWRITE_FUNCTION_EVENT_DATA' => $body ?? '', + 'APPWRITE_FUNCTION_DATA' => $body, + 'APPWRITE_FUNCTION_EVENT_DATA' => $body, 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '', 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' @@ -508,6 +508,9 @@ class Functions extends Action ]); /** Execute function */ + $error = null; + $errorCode = 0; + try { $version = $function->getAttribute('version', 'v2'); $command = $runtime['startCommand']; diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index f144c58e1b..32de1e50d6 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -4,10 +4,13 @@ namespace Appwrite\Platform\Workers; use Appwrite\Template\Template; use Exception; -use PHPMailer\PHPMailer\PHPMailer; use Swoole\Runtime; use Utopia\Database\Document; use Utopia\Logger\Log; +use Utopia\Messaging\Adapter\Email as EmailAdapter; +use Utopia\Messaging\Adapter\Email\SMTP; +use Utopia\Messaging\Messages\Email as EmailMessage; +use Utopia\Messaging\Messages\Email\Attachment; use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\Registry\Registry; @@ -49,9 +52,9 @@ class Mails extends Action /** * @param Message $message + * @param Document $project * @param Registry $register * @param Log $log - * @throws \PHPMailer\PHPMailer\Exception * @return void * @throws Exception */ @@ -132,36 +135,38 @@ class Mails extends Action // render() will return the subject in

tags, so use strip_tags() to remove them $subject = \strip_tags($subjectTemplate->render()); - /** @var PHPMailer $mail */ - $mail = empty($smtp) + /** @var EmailAdapter $adapter */ + $adapter = empty($smtp) ? $register->get('smtp') - : $this->getMailer($smtp); + : new SMTP( + host: $smtp['host'], + port: (int) $smtp['port'], + username: $smtp['username'] ?? '', + password: $smtp['password'] ?? '', + smtpSecure: $smtp['secure'] ?? '', + smtpAutoTLS: false, + xMailer: 'Appwrite Mailer', + timeout: 10, + keepAlive: true, + timelimit: 30, + ); - $mail->clearAddresses(); - $mail->clearAllRecipients(); - $mail->clearReplyTos(); - $mail->clearAttachments(); - $mail->clearBCCs(); - $mail->clearCCs(); - $mail->addAddress($recipient, $name); - $mail->Subject = $subject; - $mail->Body = $body; + // Resolve from/replyTo using fallback hierarchy: Custom options > SMTP config > Defaults + $defaultFromEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); + $defaultFromName = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); - $mail->AltBody = $body; - $mail->AltBody = preg_replace('/]*>(.*?)<\/style>/is', '', $mail->AltBody); - $mail->AltBody = \strip_tags($mail->AltBody); - $mail->AltBody = \trim($mail->AltBody); - - $replyTo = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); - $replyToName = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); + $fromEmail = !empty($smtp) ? ($smtp['senderEmail'] ?? $defaultFromEmail) : $defaultFromEmail; + $fromName = !empty($smtp) ? ($smtp['senderName'] ?? $defaultFromName) : $defaultFromName; + $replyTo = $defaultFromEmail; + $replyToName = $defaultFromName; $customMailOptions = $payload['customMailOptions'] ?? []; - // fallback hierarchy: Custom options > SMTP config > Defaults. - if (!empty($customMailOptions['senderEmail']) || !empty($customMailOptions['senderName'])) { - $fromEmail = $customMailOptions['senderEmail'] ?? $mail->From; - $fromName = $customMailOptions['senderName'] ?? $mail->FromName; - $mail->setFrom($fromEmail, $fromName); + if (!empty($customMailOptions['senderEmail'])) { + $fromEmail = $customMailOptions['senderEmail']; + } + if (!empty($customMailOptions['senderName'])) { + $fromName = $customMailOptions['senderName']; } if (!empty($customMailOptions['replyToEmail']) || !empty($customMailOptions['replyToName'])) { @@ -172,18 +177,32 @@ class Mails extends Action $replyToName = $smtp['senderName'] ?? $replyToName; } - $mail->addReplyTo($replyTo, $replyToName); + $attachments = null; if (!empty($attachment['content'] ?? '')) { - $mail->AddStringAttachment( - base64_decode($attachment['content']), - $attachment['filename'] ?? 'unknown.file', - $attachment['encoding'] ?? PHPMailer::ENCODING_BASE64, - $attachment['type'] ?? 'plain/text' - ); + $attachments = [ + new Attachment( + name: $attachment['filename'] ?? 'unknown.file', + path: '', + type: $attachment['type'] ?? 'plain/text', + content: \base64_decode($attachment['content']), + ), + ]; } + $emailMessage = new EmailMessage( + to: [['email' => $recipient, 'name' => $name]], + subject: $subject, + content: $body, + fromName: $fromName, + fromEmail: $fromEmail, + replyToName: $replyToName, + replyToEmail: $replyTo, + attachments: $attachments, + html: true, + ); + try { - $mail->send(); + $adapter->send($emailMessage); } catch (\Throwable $error) { if ($type === 'smtp') { throw new Exception('Error sending mail: ' . $error->getMessage(), 401); @@ -191,38 +210,4 @@ class Mails extends Action throw new Exception('Error sending mail: ' . $error->getMessage(), 500); } } - - /** - * @param array $smtp - * @return PHPMailer - * @throws \PHPMailer\PHPMailer\Exception - */ - protected function getMailer(array $smtp): PHPMailer - { - $mail = new PHPMailer(true); - - $mail->isSMTP(); - - $username = $smtp['username']; - $password = $smtp['password']; - - $mail->XMailer = 'Appwrite Mailer'; - $mail->Host = $smtp['host']; - $mail->Port = $smtp['port']; - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->SMTPSecure = $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.) */ - - $mail->setFrom($smtp['senderEmail'], $smtp['senderName']); - - $mail->isHTML(); - - return $mail; - } } diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index af7d2027e3..ff5eb2417a 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -285,7 +285,7 @@ class Messaging extends Action try { $response = $adapter->send($data); - $deliveredTotal += $response['deliveredTo']; + $deliveredTotal += (int) $response['deliveredTo']; foreach ($response['results'] as $result) { if ($result['status'] === 'failure') { $deliveryErrors[] = "Failed sending to target {$result['recipient']} with error: {$result['error']}"; @@ -380,7 +380,7 @@ class Messaging extends Action ])); // Delete any attachments that were downloaded to local storage - if ($provider->getAttribute('type') === MESSAGE_TYPE_EMAIL) { + if ($providerType === MESSAGE_TYPE_EMAIL) { if ($deviceForFiles->getType() === Storage::DEVICE_LOCAL) { return; } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index d96a25351f..43f5c97ba6 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -28,6 +28,7 @@ use Utopia\Locale\Locale; use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Destinations\CSV as DestinationCSV; +use Utopia\Migration\Destinations\JSON as DestinationJSON; use Utopia\Migration\Exception as MigrationException; use Utopia\Migration\Resource; use Utopia\Migration\Resources\Database\Database as ResourceDatabase; @@ -37,6 +38,7 @@ use Utopia\Migration\Source; use Utopia\Migration\Sources\Appwrite as SourceAppwrite; 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; @@ -208,8 +210,8 @@ class Migrations extends Action $getDatabasesDB = fn (Document $database): Database => $this->getDatabasesDBForProject($database); $queries = []; - if ($source === SourceAppwrite::getName() && $destination === DestinationCSV::getName()) { - $queries = Query::parseQueries($migrationOptions['queries']); + if ($source === SourceAppwrite::getName() && in_array($destination, [DestinationCSV::getName(), DestinationJSON::getName()])) { + $queries = Query::parseQueries($migrationOptions['queries'] ?? []); } $migrationSource = match ($source) { @@ -250,6 +252,12 @@ class Migrations extends Action $this->dbForProject, $getDatabasesDB ), + JSON::getName() => new JSON( + $resourceId, + $migrationOptions['path'], + $this->deviceForMigrations, + $this->dbForProject, + ), default => throw new \Exception('Invalid source type'), }; @@ -288,6 +296,13 @@ class Migrations extends Action $options['escape'], $options['header'], ), + DestinationJSON::getName() => new DestinationJSON( + $this->deviceForFiles, + $migration->getAttribute('resourceId'), + $options['bucketId'] ?? 'default', + $options['filename'], + $options['columns'] ?? [], + ), default => throw new \Exception('Invalid destination type'), }; } @@ -364,7 +379,11 @@ class Migrations extends Action 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ] ]); @@ -393,6 +412,7 @@ class Migrations extends Action $tempAPIKey = $this->generateAPIKey($project); $transfer = $source = $destination = null; + $aggregatedResources = []; $host = System::getEnv('_APP_MIGRATION_HOST'); if (empty($host)) { @@ -429,7 +449,6 @@ class Migrations extends Action $destination ); - $aggregatedResources = []; /** Start Transfer */ if (empty($source->getErrors())) { $migration->setAttribute('stage', 'migrating'); @@ -550,8 +569,9 @@ class Migrations extends Action $destination?->success(); $source?->success(); } - if ($migration->getAttribute('destination') === DestinationCSV::getName()) { - $this->handleCSVExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization); + $destination_type = $migration->getAttribute('destination'); + if ($destination_type === DestinationCSV::getName() || $destination_type === DestinationJSON::getName()) { + $this->handleDataExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization); } } finally { $source?->cleanup(); @@ -583,7 +603,7 @@ class Migrations extends Action * @param Authorization $authorization * @return void */ - protected function handleCSVExportComplete( + protected function handleDataExportComplete( Document $project, Document $migration, Mail $queueForMails, @@ -608,7 +628,8 @@ class Migrations extends Action throw new \Exception('Bucket not found'); } - $path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . '.csv'); + $extension = $migration->getAttribute('destination') === DestinationJSON::getName() ? '.json' : '.csv'; + $path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . $extension); $size = $this->deviceForFiles->getFileSize($path); $mime = $this->deviceForFiles->getFileMimeType($path); $hash = $this->deviceForFiles->getFileHash($path); @@ -632,13 +653,14 @@ class Migrations extends Action $migration->setAttribute('errors', $errors); $migration = $this->updateMigrationDocument($migration, $project, $queueForRealtime); - $this->sendCSVEmail( + $this->sendExportEmail( success: false, project: $project, user: $user, options: $options, queueForMails: $queueForMails, platform: $platform, + exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV', sizeMB: $sizeMB ); @@ -694,13 +716,14 @@ class Migrations extends Action $migration->setAttribute('options', $options); $this->updateMigrationDocument($migration, $project, $queueForRealtime); - $this->sendCSVEmail( + $this->sendExportEmail( success: true, project: $project, user: $user, options: $options, queueForMails: $queueForMails, platform: $platform, + exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV', downloadUrl: $downloadUrl ); } @@ -719,13 +742,14 @@ class Migrations extends Action * @return void * @throws \Exception */ - protected function sendCSVEmail( + protected function sendExportEmail( bool $success, Document $project, Document $user, array $options, Mail $queueForMails, array $platform, + string $exportType = 'CSV', string $downloadUrl = '', float $sizeMB = 0.0, ): void { @@ -745,15 +769,15 @@ class Migrations extends Action ? 'success' : 'failure'; - // Get localized email content - $subject = $locale->getText("emails.csvExport.{$emailType}.subject"); - $preview = $locale->getText("emails.csvExport.{$emailType}.preview"); - $hello = $locale->getText("emails.csvExport.{$emailType}.hello"); - $body = $locale->getText("emails.csvExport.{$emailType}.body"); - $footer = $locale->getText("emails.csvExport.{$emailType}.footer"); - $thanks = $locale->getText("emails.csvExport.{$emailType}.thanks"); - $signature = $locale->getText("emails.csvExport.{$emailType}.signature"); - $buttonText = $success ? $locale->getText("emails.csvExport.{$emailType}.buttonText") : ''; + // Get localized email content — replace {{type}} with export format (CSV/JSON) + $subject = \str_replace('{{type}}', $exportType, $locale->getText("emails.dataExport.{$emailType}.subject")); + $preview = \str_replace('{{type}}', $exportType, $locale->getText("emails.dataExport.{$emailType}.preview")); + $hello = $locale->getText("emails.dataExport.{$emailType}.hello"); + $body = $locale->getText("emails.dataExport.{$emailType}.body"); + $footer = $locale->getText("emails.dataExport.{$emailType}.footer"); + $thanks = $locale->getText("emails.dataExport.{$emailType}.thanks"); + $signature = $locale->getText("emails.dataExport.{$emailType}.signature"); + $buttonText = $success ? $locale->getText("emails.dataExport.{$emailType}.buttonText") : ''; // Build email body using appropriate template $templatePath = $success @@ -769,6 +793,7 @@ class Migrations extends Action ->setParam('{{direction}}', $locale->getText('settings.direction')) ->setParam('{{project}}', $project->getAttribute('name')) ->setParam('{{user}}', $user->getAttribute('name', $user->getAttribute('email'))) + ->setParam('{{type}}', $exportType) ->setParam('{{size}}', $success ? '' : (string)$sizeMB); if ($success) { @@ -789,6 +814,7 @@ class Migrations extends Action 'terms' => $platform['termsUrl'], 'privacy' => $platform['privacyUrl'], 'platform' => $platform['platformName'], + 'type' => $exportType, ]; $queueForMails diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index 0e7a9bb0a7..b2823d3722 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -208,7 +208,7 @@ class StatsResources extends Action { $totalFiles = 0; $totalStorage = 0; - $this->foreachDocument($dbForProject, 'buckets', [], function ($bucket) use ($dbForProject, $dbForLogs, $region, &$totalFiles, &$totalStorage) { + $this->foreachDocument($dbForProject, 'buckets', [], function ($bucket) use ($dbForProject, $region, &$totalFiles, &$totalStorage) { try { $files = $dbForProject->count('bucket_' . $bucket->getSequence()); } catch (Throwable $th) { diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 1e0a2eabba..144c429629 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -140,7 +140,7 @@ class StatsUsage extends Action /** * @param Message $message - * @param callable(): Database $getProjectDB + * @param callable(Document): Database $getProjectDB * @param callable(): Database $getLogsDB * @param Registry $register * @return void @@ -212,7 +212,7 @@ class StatsUsage extends Action * @param Document $project * @param Document $document * @param array $metrics - * @param callable(): Database $getProjectDB + * @param callable(Document): Database $getProjectDB * @param string $databaseType Database type from context * @return void */ @@ -394,7 +394,7 @@ class StatsUsage extends Action /** * Commit stats to DB - * @param callable(): Database $getProjectDB + * @param callable(Document): Database $getProjectDB * @return void */ public function commitToDb(callable $getProjectDB): void @@ -459,7 +459,7 @@ class StatsUsage extends Action /** * Sort by unique index key reduce locks/deadlocks */ - usort($projectStats['stats'], function ($a, $b) use ($sequence) { + usort($projectStats['stats'], function ($a, $b) { // Metric DESC $cmp = strcmp($b['metric'], $a['metric']); if ($cmp !== 0) { diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index fce3c7b149..509f0a6313 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -233,7 +233,7 @@ class Webhooks extends Action $template->setParam('{{webhook}}', $webhook->getAttribute('name')); $template->setParam('{{project}}', $project->getAttribute('name')); $template->setParam('{{url}}', $webhook->getAttribute('url')); - $template->setParam('{{error}}', $curlError ?? 'The server returned ' . $statusCode . ' status code'); + $template->setParam('{{error}}', 'The server returned ' . $statusCode . ' status code'); $template->setParam('{{path}}', "/console/project-$region-$projectId/settings/webhooks/$webhookId"); $template->setParam('{{attempts}}', $attempts); diff --git a/src/Appwrite/Promises/Promise.php b/src/Appwrite/Promises/Promise.php index a6b1aa79d5..a58c7c29a8 100644 --- a/src/Appwrite/Promises/Promise.php +++ b/src/Appwrite/Promises/Promise.php @@ -2,6 +2,7 @@ namespace Appwrite\Promises; +/** @phpstan-consistent-constructor */ abstract class Promise { protected const STATE_PENDING = 1; diff --git a/src/Appwrite/Promises/Swoole.php b/src/Appwrite/Promises/Swoole.php index c258ef6a5e..9c06fbda2f 100644 --- a/src/Appwrite/Promises/Swoole.php +++ b/src/Appwrite/Promises/Swoole.php @@ -2,10 +2,14 @@ namespace Appwrite\Promises; +use Swoole\Coroutine; use Swoole\Coroutine\Channel; +use Utopia\DI\Container; class Swoole extends Promise { + private const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container'; + public function __construct(?callable $executor = null) { parent::__construct($executor); @@ -16,7 +20,14 @@ class Swoole extends Promise callable $resolve, callable $reject ): void { - \go(function () use ($executor, $resolve, $reject) { + $parentContainer = (Coroutine::getCid() !== -1) + ? (Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? null) + : null; + + \go(function () use ($executor, $resolve, $reject, $parentContainer) { + if ($parentContainer !== null) { + Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = new Container($parentContainer); + } try { $executor($resolve, $reject); } catch (\Throwable $exception) { diff --git a/src/Appwrite/SDK/Method.php b/src/Appwrite/SDK/Method.php index 3cf96b5413..c74406b2a6 100644 --- a/src/Appwrite/SDK/Method.php +++ b/src/Appwrite/SDK/Method.php @@ -197,7 +197,7 @@ class Method public function isHidden(): bool|array { - return $this->hide ?? false; + return $this->hide; } public function isPackaging(): bool diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 04ecafa8fc..02fac12a7a 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -4,12 +4,12 @@ namespace Appwrite\SDK\Specification; use Appwrite\Utopia\Response\Model; use Utopia\Config\Config; -use Utopia\Http\Http; +use Utopia\DI\Container; use Utopia\Http\Route; abstract class Format { - protected Http $app; + protected Container $container; /** * @var array @@ -80,9 +80,9 @@ abstract class Format protected array $enumBlacklist = []; - public function __construct(Http $app, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) + public function __construct(Container $container, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) { - $this->app = $app; + $this->container = $container; $this->services = $services; $this->routes = $routes; $this->models = $models; @@ -204,15 +204,53 @@ abstract class Format * * Get services value * - * @param array $services - * - * @return self */ public function getServices(): array { return $this->services; } + /** + * @param list $injections + * @return array + */ + protected function getResources(array $injections): array + { + $resources = []; + + foreach ($injections as $name) { + $resources[$name] = $this->container->get($name); + } + + return $resources; + } + + protected function getValidator(array $param): mixed + { + return \is_callable($param['validator']) + ? ($param['validator'])(...$this->getResources($param['injections'] ?? [])) + : $param['validator']; + } + + protected function getDescriptionContents(?string $description): string + { + if ($description === null || $description === '') { + return ''; + } + + if (!\str_ends_with($description, '.md')) { + return $description; + } + + $contents = @\file_get_contents($description); + + if ($contents === false) { + throw new \RuntimeException('Documentation file not found or unreadable: ' . $description); + } + + return $contents; + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ @@ -753,6 +791,9 @@ abstract class Format protected function getNestedModels(Model $model, array &$usedModels): void { foreach ($model->getRules() as $rule) { + if (($rule['hidden'] ?? false) === true) { + continue; + } if (!in_array($model->getType(), $usedModels)) { continue; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 8c77da413f..7da48fc2ca 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -79,8 +79,8 @@ class OpenAPI3 extends Format $output['components']['securitySchemes']['Key']['x-appwrite'] = ['demo' => '']; } - if (isset($output['securityDefinitions']['JWT'])) { - $output['securityDefinitions']['JWT']['x-appwrite'] = ['demo' => '']; + if (isset($output['components']['securitySchemes']['JWT'])) { + $output['components']['securitySchemes']['JWT']['x-appwrite'] = ['demo' => '']; } if (isset($output['components']['securitySchemes']['Locale'])) { @@ -99,7 +99,7 @@ class OpenAPI3 extends Format $sdk = $route->getLabel('sdk', false); - if (empty($sdk)) { + if ($sdk === false) { continue; } @@ -125,8 +125,7 @@ class OpenAPI3 extends Format $namespace = $sdk->getNamespace() ?? 'default'; - $desc ??= ''; - $descContents = \str_ends_with($desc, '.md') ? \file_get_contents($desc) : $desc; + $descContents = $this->getDescriptionContents($desc); $temp = [ 'summary' => $route->getDesc(), @@ -163,7 +162,7 @@ class OpenAPI3 extends Format ]; } - if (!empty($additionalMethods)) { + if (\is_array($additionalMethods) && \count($additionalMethods) > 0) { $temp['x-appwrite']['methods'] = []; foreach ($additionalMethods as $methodObj) { /** @var Method $methodObj */ @@ -191,7 +190,7 @@ class OpenAPI3 extends Format 'parameters' => [], 'required' => [], 'responses' => [], - 'description' => ($desc) ? \file_get_contents($desc) : '', + 'description' => $this->getDescriptionContents($desc), 'demo' => \strtolower($namespace) . '/' . Template::fromCamelCaseToDash($methodObj->getMethodName()) . '.md', 'public' => $methodObj->isPublic(), ]; @@ -279,6 +278,18 @@ class OpenAPI3 extends Format } } + if (\is_string($model)) { + throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + + if (\is_array($model)) { + foreach ($model as $m) { + if (\is_string($m)) { + throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + } + } + if (!(\is_array($model)) && $model->isNone()) { $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => in_array($produces, [ @@ -329,7 +340,7 @@ class OpenAPI3 extends Format if (($response->getCode() ?? 500) === 204) { $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['schema']); + unset($temp['responses'][(string)$response->getCode() ?? '500']['content']); } } @@ -368,7 +379,7 @@ class OpenAPI3 extends Format /** * @var \Utopia\Validator $validator */ - $validator = (\is_callable($param['validator'])) ? call_user_func_array($param['validator'], $this->app->getResources($param['injections'])) : $param['validator']; + $validator = $this->getValidator($param); $node = [ 'name' => $name, @@ -383,7 +394,7 @@ class OpenAPI3 extends Format $validator = $validator->getValidator(); } - $class = !empty($validator) + $class = $validator instanceof Validator ? \get_class($validator) : ''; @@ -431,7 +442,7 @@ class OpenAPI3 extends Format $node['schema']['type'] = $validator->getType(); $node['schema']['x-example'] = ($param['example'] ?? '') ?: '<' . \strtoupper(Template::fromCamelCaseToSnake($node['name'])) . '>'; break; - case \Utopia\Database\Validator\DatetimeValidator::class: + case \Utopia\Database\Validator\Datetime::class: $node['schema']['type'] = $validator->getType(); $node['schema']['format'] = 'datetime'; $node['schema']['x-example'] = ($param['example'] ?? '') ?: Model::TYPE_DATETIME_EXAMPLE; @@ -463,7 +474,6 @@ class OpenAPI3 extends Format $node['schema']['x-example'] = ($param['example'] ?? '') ?: 'https://example.com'; break; case \Utopia\Validator\JSON::class: - case \Utopia\Validator\Mock::class: case \Utopia\Validator\Assoc::class: $param['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; $node['schema']['type'] = 'object'; @@ -563,12 +573,6 @@ class OpenAPI3 extends Format $node['schema']['x-example'] = $param['example']; } break; - case \Utopia\Validator\Length::class: - $node['schema']['type'] = $validator->getType(); - if (!empty($param['example'])) { - $node['schema']['x-example'] = $param['example']; - } - break; case \Utopia\Validator\WhiteList::class: if ($array) { $validator = $validator->getValidator(); @@ -829,6 +833,10 @@ class OpenAPI3 extends Format } foreach ($model->getRules() as $name => $rule) { + if (($rule['hidden'] ?? false) === true) { + continue; + } + $type = ''; $format = null; $items = null; diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d0815d8cad..d95f99bb70 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -96,10 +96,9 @@ class Swagger2 extends Format $scope = $route->getLabel('scope', ''); - /** @var Method $sdk */ $sdk = $route->getLabel('sdk', false); - if (empty($sdk)) { + if ($sdk === false) { continue; } @@ -127,8 +126,7 @@ class Swagger2 extends Format $sdkPlatforms = array_values(array_unique($sdkPlatforms)); $namespace = $sdk->getNamespace() ?? 'default'; - $desc ??= ''; - $descContents = \str_ends_with($desc, '.md') ? \file_get_contents($desc) : $desc; + $descContents = $this->getDescriptionContents($desc); $temp = [ 'summary' => $route->getDesc(), @@ -171,7 +169,7 @@ class Swagger2 extends Format $temp['produces'][] = $produces; } - if (!empty($additionalMethods)) { + if (\is_array($additionalMethods) && \count($additionalMethods) > 0) { $temp['x-appwrite']['methods'] = []; foreach ($additionalMethods as $methodObj) { /** @var Method $methodObj */ @@ -200,7 +198,7 @@ class Swagger2 extends Format 'parameters' => [], 'required' => [], 'responses' => [], - 'description' => ($desc) ? \file_get_contents($desc) : '', + 'description' => $this->getDescriptionContents($desc), 'demo' => \strtolower($namespace) . '/' . Template::fromCamelCaseToDash($methodObj->getMethodName()) . '.md', 'public' => $methodObj->isPublic(), ]; @@ -287,6 +285,18 @@ class Swagger2 extends Format } } + if (\is_string($model)) { + throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + + if (\is_array($model)) { + foreach ($model as $m) { + if (\is_string($m)) { + throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + } + } + if (!(\is_array($model)) && $model->isNone()) { $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => in_array($produces, [ @@ -371,9 +381,7 @@ class Swagger2 extends Format } /** @var Validator $validator */ - $validator = (\is_callable($param['validator'])) - ? ($param['validator'])(...$this->app->getResources($param['injections'])) - : $param['validator']; + $validator = $this->getValidator($param); $node = [ 'name' => $name, @@ -388,7 +396,7 @@ class Swagger2 extends Format $validator = $validator->getValidator(); } - $class = !empty($validator) + $class = $validator instanceof Validator ? \get_class($validator) : ''; @@ -436,7 +444,7 @@ class Swagger2 extends Format $node['type'] = $validator->getType(); $node['x-example'] = ($param['example'] ?? '') ?: '<' . \strtoupper(Template::fromCamelCaseToSnake($node['name'])) . '>'; break; - case \Utopia\Database\Validator\DatetimeValidator::class: + case \Utopia\Database\Validator\Datetime::class: $node['type'] = $validator->getType(); $node['format'] = 'datetime'; $node['x-example'] = ($param['example'] ?? '') ?: Model::TYPE_DATETIME_EXAMPLE; @@ -479,7 +487,6 @@ class Swagger2 extends Format } break; case \Utopia\Validator\JSON::class: - case \Utopia\Validator\Mock::class: case \Utopia\Validator\Assoc::class: $node['type'] = 'object'; $node['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; @@ -552,12 +559,6 @@ class Swagger2 extends Format $node['x-example'] = $param['example']; } break; - case \Utopia\Validator\Length::class: - $node['type'] = $validator->getType(); - if (!empty($param['example'])) { - $node['x-example'] = $param['example']; - } - break; case \Utopia\Validator\WhiteList::class: if ($array) { $validator = $validator->getValidator(); @@ -810,6 +811,10 @@ class Swagger2 extends Format } foreach ($model->getRules() as $name => $rule) { + if (($rule['hidden'] ?? false) === true) { + continue; + } + $type = ''; $format = null; $items = null; diff --git a/src/Appwrite/Template/Template.php b/src/Appwrite/Template/Template.php index c8744c87bb..695e925e52 100644 --- a/src/Appwrite/Template/Template.php +++ b/src/Appwrite/Template/Template.php @@ -149,7 +149,7 @@ class Template extends View /** * From Camel Case * - * @var string $input + * @param string $input * * @return string */ @@ -167,7 +167,7 @@ class Template extends View /** * From Camel Case to Dash Case * - * @var string $input + * @param string $input * * @return string */ diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index bef73b31d9..211c6449dc 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -86,7 +86,6 @@ class User extends Document /** * Check if user is anonymous. * - * @param Document $this * @return bool */ public function isAnonymous(): bool @@ -102,7 +101,7 @@ class User extends Document * * @return bool */ - public static function isPrivileged(array $roles): bool + public function isPrivileged(array $roles): bool { if ( in_array(self::ROLE_OWNER, $roles) || @@ -122,7 +121,7 @@ class User extends Document * * @return bool */ - public static function isApp(array $roles): bool + public function isApp(array $roles): bool { if (in_array(self::ROLE_APPS, $roles)) { return true; @@ -153,7 +152,6 @@ class User extends Document /** * Verify session and check that its not expired. * - * @param array $sessions * @param string $secret * * @return bool|string @@ -175,7 +173,5 @@ class User extends Document } return false; - - return false; } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Platforms.php b/src/Appwrite/Utopia/Database/Validator/Queries/Platforms.php new file mode 100644 index 0000000000..525c832f8d --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Platforms.php @@ -0,0 +1,25 @@ + */ private array $filters = []; - private static ?Route $route = null; + private ?Route $route = null; public function __construct(SwooleRequest $request) { @@ -34,11 +34,11 @@ class Request extends UtopiaRequest { $parameters = parent::getParams(); - if (!$this->hasFilters() || !self::hasRoute()) { + if (!$this->hasFilters() || !$this->hasRoute()) { return $parameters; } - $methods = self::getRoute()->getLabel('sdk', null); + $methods = $this->getRoute()?->getLabel('sdk', null); if (empty($methods)) { return $parameters; @@ -131,9 +131,9 @@ class Request extends UtopiaRequest * * @return void */ - public static function setRoute(?Route $route): void + public function setRoute(?Route $route): void { - self::$route = $route; + $this->route = $route; } /** @@ -141,9 +141,9 @@ class Request extends UtopiaRequest * * @return Route|null */ - public static function getRoute(): ?Route + public function getRoute(): ?Route { - return self::$route; + return $this->route; } /** @@ -151,9 +151,9 @@ class Request extends UtopiaRequest * * @return bool */ - public static function hasRoute(): bool + public function hasRoute(): bool { - return self::$route !== null; + return $this->route !== null; } /** @@ -215,7 +215,7 @@ class Request extends UtopiaRequest $forwardedUserAgent = $this->getHeader('x-forwarded-user-agent'); if (!empty($forwardedUserAgent)) { $roles = $this->authorization->getRoles(); - $isAppUser = User::isApp($roles); + $isAppUser = $this->user?->isApp($roles) ?? false; if ($isAppUser) { return $forwardedUserAgent; @@ -239,9 +239,15 @@ class Request extends UtopiaRequest } private ?Authorization $authorization = null; + private ?User $user = null; public function setAuthorization(Authorization $authorization): void { $this->authorization = $authorization; } + + public function setUser(User $user): void + { + $this->user = $user; + } } diff --git a/src/Appwrite/Utopia/Request/Filters/V17.php b/src/Appwrite/Utopia/Request/Filters/V17.php index 2cdf3973b2..0e4f9eceb6 100644 --- a/src/Appwrite/Utopia/Request/Filters/V17.php +++ b/src/Appwrite/Utopia/Request/Filters/V17.php @@ -120,11 +120,11 @@ class V17 extends Filter $isArrayStack = !$isStringStack && $stackCount > 0; if ($char === static::CHAR_BACKSLASH) { - if (!(static::isSpecialChar($filter[$i + 1]))) { - static::appendSymbol($isStringStack, $filter[$i], $i, $filter, $currentParam); + if (!(self::isSpecialChar($filter[$i + 1]))) { + self::appendSymbol($isStringStack, $filter[$i], $i, $filter, $currentParam); } - static::appendSymbol($isStringStack, $filter[$i + 1], $i, $filter, $currentParam); + self::appendSymbol($isStringStack, $filter[$i + 1], $i, $filter, $currentParam); $i++; continue; @@ -147,7 +147,7 @@ class V17 extends Filter } // Either way, add symbol to builder - static::appendSymbol( + self::appendSymbol( $isStringStack, $char, $i, @@ -199,7 +199,7 @@ class V17 extends Filter } // Value, not relevant to syntax - static::appendSymbol( + self::appendSymbol( $isStringStack, $char, $i, diff --git a/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 1fd6ba9dc4..357f00cfdc 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -11,9 +11,69 @@ class V21 extends Filter public function parse(array $content, string $model): array { switch ($model) { + // Web is special case compared to others, because it holds backwards compatibility logic + case 'project.createWebPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + // Keep 'key' for backwards compatibility + break; + case 'project.updateWebPlatform': + $content = $this->removePlatformStore($content); + // Keep 'key' for backwards compatibility + break; + case 'project.createApplePlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'bundleIdentifier'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateApplePlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'bundleIdentifier'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.createAndroidPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'applicationId'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateAndroidPlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'applicationId'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.createWindowsPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageIdentifierName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateWindowsPlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageIdentifierName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.createLinuxPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateLinuxPlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.listPlatforms': + $content = $this->preservePlatformsQueries($content); + break; case 'webhooks.create': $content = $this->fillWebhookid($content); break; + case 'project.createKey': + $content = $this->fillKeyId($content); + break; case 'project.createVariable': $content = $this->fillVariableId($content); break; @@ -65,6 +125,12 @@ class V21 extends Filter return $content; } + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + protected function fillVariableId(array $content): array { $content['variableId'] = $content['variableId'] ?? 'unique()'; @@ -79,4 +145,33 @@ class V21 extends Filter return $content; } + + protected function fillPlatformId(array $content): array + { + $content['platformId'] = $content['platformId'] ?? 'unique()'; + return $content; + } + + protected function replacePlatformKey(array $content, string $newKey): array + { + $content[$newKey] = $content[$newKey] ?? $content['key'] ?? null; + unset($content['key']); + + return $content; + } + + protected function removePlatformStore(array $content): array + { + unset($content['store']); + return $content; + } + + protected function preservePlatformsQueries(array $content): array + { + $content['queries'] = $content['queries'] ?? [ + Query::limit(5000) + ]; + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index c2fc520da3..295348c665 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -256,7 +256,11 @@ class Response extends SwooleResponse public const MODEL_MOCK_NUMBER = 'mockNumber'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; - public const MODEL_PLATFORM = 'platform'; + public const MODEL_PLATFORM_APPLE = 'platformApple'; + public const MODEL_PLATFORM_ANDROID = 'platformAndroid'; + public const MODEL_PLATFORM_WINDOWS = 'platformWindows'; + public const MODEL_PLATFORM_LINUX = 'platformLinux'; + public const MODEL_PLATFORM_WEB = 'platformWeb'; public const MODEL_PLATFORM_LIST = 'platformList'; public const MODEL_VARIABLE = 'variable'; public const MODEL_VARIABLE_LIST = 'variableList'; @@ -299,7 +303,7 @@ class Response extends SwooleResponse /** * @var bool */ - protected static bool $showSensitive = false; + protected bool $showSensitive = false; /** * @var array @@ -476,7 +480,13 @@ class Response extends SwooleResponse foreach ($rule['type'] as $type) { $condition = false; foreach ($this->getModel($type)->conditions as $attribute => $val) { - $condition = $item->getAttribute($attribute) === $val; + + if (\is_array($val)) { + $condition = \in_array($item->getAttribute($attribute), $val); + } else { + $condition = $item->getAttribute($attribute) === $val; + } + if (!$condition) { break; } @@ -505,10 +515,11 @@ class Response extends SwooleResponse if ($rule['sensitive']) { $roles = $this->authorization->getRoles(); - $isPrivilegedUser = DBUser::isPrivileged($roles); - $isAppUser = DBUser::isApp($roles); + $user = $this->user ?? new DBUser(); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); - if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { + if ((!$isPrivilegedUser && !$isAppUser) && !$this->showSensitive) { $data->setAttribute($key, ''); } } @@ -628,9 +639,9 @@ class Response extends SwooleResponse } /** - * Return the currently set filter + * Return the currently set filters * - * @return Filter + * @return array */ public function getFilters(): array { @@ -658,25 +669,33 @@ class Response extends SwooleResponse } /** - * Static wrapper to show sensitive data in response + * Wrapper to show sensitive data in response * - * @param callable The callback to show sensitive information for + * @param callable(): array $callback The callback to show sensitive information for * @return array */ - public static function showSensitive(callable $callback): array + public function showSensitive(callable $callback): array { + $previous = $this->showSensitive; + try { - self::$showSensitive = true; + $this->showSensitive = true; return $callback(); } finally { - self::$showSensitive = false; + $this->showSensitive = $previous; } } private ?Authorization $authorization = null; + private ?DBUser $user = null; public function setAuthorization(Authorization $authorization): void { $this->authorization = $authorization; } + + public function setUser(DBUser $user): void + { + $this->user = $user; + } } diff --git a/src/Appwrite/Utopia/Response/Filters/V21.php b/src/Appwrite/Utopia/Response/Filters/V21.php index b65e26a8b0..128662a409 100644 --- a/src/Appwrite/Utopia/Response/Filters/V21.php +++ b/src/Appwrite/Utopia/Response/Filters/V21.php @@ -11,34 +11,65 @@ class V21 extends Filter public function parse(array $content, string $model): array { return match ($model) { + // Web is special case, it has backwards compatibility + Response::MODEL_PLATFORM_WEB => $this->parsePlatform($content), + Response::MODEL_PLATFORM_APPLE => $this->parsePlatform($content), + Response::MODEL_PLATFORM_ANDROID => $this->parsePlatform($content), + Response::MODEL_PLATFORM_WINDOWS => $this->parsePlatform($content), + Response::MODEL_PLATFORM_LINUX => $this->parsePlatform($content), + Response::MODEL_PLATFORM_LIST => $this->handleList( + $content, + "platforms", + fn ($item) => $this->parsePlatform($item), + ), + Response::MODEL_PROJECT => $this->parseProjectForPlatform($content), + Response::MODEL_PROJECT_LIST => $this->handleList( + $content, + "projects", + fn ($item) => $this->parseProjectForPlatform($item), + ), + Response::MODEL_USER => $this->parseUser($content), + Response::MODEL_USER_LIST => $this->handleList( + $content, + 'users', + fn ($item) => $this->parseUser($item), + ), + Response::MODEL_ACCOUNT => $this->parseUser($content), Response::MODEL_SITE => $this->parseSite($content), Response::MODEL_SITE_LIST => $this->handleList( $content, - "sites", + 'sites', fn ($item) => $this->parseSite($item), ), Response::MODEL_FUNCTION => $this->parseFunction($content), Response::MODEL_FUNCTION_LIST => $this->handleList( $content, - "functions", + 'functions', fn ($item) => $this->parseFunction($item), ), Response::MODEL_DOCUMENT => $this->parseDocument($content), Response::MODEL_DOCUMENT_LIST => $this->handleList( $content, - "documents", + 'documents', fn ($item) => $this->parseDocument($item), ), Response::MODEL_ROW => $this->parseRow($content), Response::MODEL_ROW_LIST => $this->handleList( $content, - "rows", + 'rows', fn ($item) => $this->parseRow($item), ), default => $content, }; } + protected function parseUser(array $content): array + { + unset($content['impersonator']); + unset($content['impersonatorUserId']); + return $content; + } + protected function parseSite(array $content): array { $content = $this->parseSpecs($content); @@ -93,4 +124,34 @@ class V21 extends Filter return $content; } + + protected function parseProjectForPlatform(array $content): array + { + // Parse platforms under project, since it's a subquery + $content['platforms'] = \array_map(fn ($item) => $this->parsePlatform($item), $content['platforms']); + return $content; + } + + protected function parsePlatform(array $content): array + { + // Map platform-specific identifier fields back to 'key' + $content['key'] = + ($content['bundleIdentifier'] ?? '') + ?: ($content['applicationId'] ?? '') + ?: ($content['packageIdentifierName'] ?? '') + ?: ($content['packageName'] ?? '') + ?: ($content['key'] ?? '') + ?: ''; + + unset($content['bundleIdentifier']); + unset($content['applicationId']); + unset($content['packageIdentifierName']); + unset($content['packageName']); + + // Restore fields removed in v1.9 + $content['store'] = $content['store'] ?? ''; + $content['hostname'] = $content['hostname'] ?? ''; + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response/Model/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php index 0171a3c152..2b8f962cd0 100644 --- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php +++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class AuthProvider extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/DevKey.php b/src/Appwrite/Utopia/Response/Model/DevKey.php index b8da6c0cfc..45434cde3b 100644 --- a/src/Appwrite/Utopia/Response/Model/DevKey.php +++ b/src/Appwrite/Utopia/Response/Model/DevKey.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class DevKey extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/Log.php b/src/Appwrite/Utopia/Response/Model/Log.php index a8c00280d3..1e48a6bc89 100644 --- a/src/Appwrite/Utopia/Response/Model/Log.php +++ b/src/Appwrite/Utopia/Response/Model/Log.php @@ -40,6 +40,12 @@ class Log extends Model 'default' => '', 'example' => 'admin', ]) + ->addRule('userType', [ + 'type' => self::TYPE_STRING, + 'description' => 'User type who triggered the audit log. Possible values: user, admin, guest, keyProject, keyAccount, keyOrganization.', + 'default' => '', + 'example' => 'user', + ]) ->addRule('ip', [ 'type' => self::TYPE_STRING, 'description' => 'IP session in use when the session was created.', diff --git a/src/Appwrite/Utopia/Response/Model/MFAType.php b/src/Appwrite/Utopia/Response/Model/MFAType.php index 5f4a272796..807d3f5c8b 100644 --- a/src/Appwrite/Utopia/Response/Model/MFAType.php +++ b/src/Appwrite/Utopia/Response/Model/MFAType.php @@ -14,13 +14,13 @@ class MFAType extends Model 'type' => self::TYPE_STRING, 'description' => 'Secret token used for TOTP factor.', 'default' => '', - 'example' => true + 'example' => '[SHARED_SECRET]' ]) ->addRule('uri', [ 'type' => self::TYPE_STRING, 'description' => 'URI for authenticator apps.', 'default' => '', - 'example' => true + 'example' => 'otpauth://totp/appwrite:user@example.com?secret=[SHARED_SECRET]&issuer=appwrite' ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/Platform.php b/src/Appwrite/Utopia/Response/Model/Platform.php deleted file mode 100644 index 151e43780d..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Platform.php +++ /dev/null @@ -1,100 +0,0 @@ -addRule('$id', [ - 'type' => self::TYPE_STRING, - 'description' => 'Platform ID.', - 'default' => '', - 'example' => '5e5ea5c16897e', - ]) - ->addRule('$createdAt', [ - 'type' => self::TYPE_DATETIME, - 'description' => 'Platform creation date in ISO 8601 format.', - 'default' => '', - 'example' => self::TYPE_DATETIME_EXAMPLE, - ]) - ->addRule('$updatedAt', [ - 'type' => self::TYPE_DATETIME, - 'description' => 'Platform update date in ISO 8601 format.', - 'default' => '', - 'example' => self::TYPE_DATETIME_EXAMPLE, - ]) - ->addRule('name', [ - 'type' => self::TYPE_STRING, - 'description' => 'Platform name.', - 'default' => '', - 'example' => 'My Web App', - ]) - ->addRule('type', [ - 'type' => self::TYPE_ENUM, - 'description' => 'Platform type. Possible values are: web, flutter-web, flutter-ios, flutter-android, flutter-linux, flutter-macos, flutter-windows, apple-ios, apple-macos, apple-watchos, apple-tvos, android, unity, react-native-ios, react-native-android.', - 'default' => '', - 'example' => 'web', - 'enum' => ['web', 'flutter-web', 'flutter-ios', 'flutter-android', 'flutter-linux', 'flutter-macos', 'flutter-windows', 'apple-ios', 'apple-macos', 'apple-watchos', 'apple-tvos', 'android', 'unity', 'react-native-ios', 'react-native-android'], - ]) - ->addRule('key', [ - 'type' => self::TYPE_STRING, - 'description' => 'Platform Key. iOS bundle ID or Android package name. Empty string for other platforms.', - 'default' => '', - 'example' => 'com.company.appname', - ]) - ->addRule('store', [ - 'type' => self::TYPE_STRING, - 'description' => 'App store or Google Play store ID.', - 'example' => '', - ]) - ->addRule('hostname', [ - 'type' => self::TYPE_STRING, - 'description' => 'Web app hostname. Empty string for other platforms.', - 'default' => '', - 'example' => 'app.example.com', - ]) - ->addRule('httpUser', [ - 'type' => self::TYPE_STRING, - 'description' => 'HTTP basic authentication username.', - 'default' => '', - 'example' => 'username', - ]) - ->addRule('httpPass', [ - 'type' => self::TYPE_STRING, - 'description' => 'HTTP basic authentication password.', - 'default' => '', - 'example' => 'password', - ]) - ; - } - - /** - * Get Name - * - * @return string - */ - public function getName(): string - { - return 'Platform'; - } - - /** - * Get Type - * - * @return string - */ - public function getType(): string - { - return Response::MODEL_PLATFORM; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformAndroid.php b/src/Appwrite/Utopia/Response/Model/PlatformAndroid.php new file mode 100644 index 0000000000..007cffedde --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformAndroid.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_ANDROID, + ]; + + parent::__construct(); + + $this + ->addRule('applicationId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Android application ID.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Android'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_ANDROID; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'applicationId' + $document->setAttribute('applicationId', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformApple.php b/src/Appwrite/Utopia/Response/Model/PlatformApple.php new file mode 100644 index 0000000000..b9154e659d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformApple.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_APPLE, + ]; + + parent::__construct(); + + $this + ->addRule('bundleIdentifier', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple bundle identifier.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Apple'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_APPLE; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'bundleIdentifier' + $document->setAttribute('bundleIdentifier', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformBase.php b/src/Appwrite/Utopia/Response/Model/PlatformBase.php new file mode 100644 index 0000000000..1b7ec75e6d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformBase.php @@ -0,0 +1,57 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Platform ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Platform creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Platform update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('name', [ + 'type' => self::TYPE_STRING, + 'description' => 'Platform name.', + 'default' => '', + 'example' => 'My Web App', + ]) + ->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Platform type. Possible values are: ' . implode(', ', self::getSupportedTypes()) . '.', + 'default' => '', + 'example' => Platform::TYPE_WEB, + 'enum' => self::getSupportedTypes(), + ]) + ; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformLinux.php b/src/Appwrite/Utopia/Response/Model/PlatformLinux.php new file mode 100644 index 0000000000..66bc679b37 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformLinux.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_LINUX, + ]; + + parent::__construct(); + + $this + ->addRule('packageName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Linux package name.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Linux'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_LINUX; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'packageName' + $document->setAttribute('packageName', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformList.php b/src/Appwrite/Utopia/Response/Model/PlatformList.php new file mode 100644 index 0000000000..7ad7ffed48 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformList.php @@ -0,0 +1,53 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of platforms in the given project.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('platforms', [ + 'type' => [ + Response::MODEL_PLATFORM_WEB, + Response::MODEL_PLATFORM_APPLE, + Response::MODEL_PLATFORM_ANDROID, + Response::MODEL_PLATFORM_WINDOWS, + Response::MODEL_PLATFORM_LINUX, + ], + 'description' => 'List of platforms.', + 'default' => [], + 'array' => true + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platforms List'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformWeb.php b/src/Appwrite/Utopia/Response/Model/PlatformWeb.php new file mode 100644 index 0000000000..af03194fdb --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformWeb.php @@ -0,0 +1,71 @@ +conditions = [ + 'type' => [ + Platform::TYPE_WEB, + // Backwards compatibility + 'flutter-web', + 'unity', + 'flutter-macos', + 'flutter-ios', + 'react-native-ios', + 'apple-ios', + 'apple-macos', + 'apple-watchos', + 'apple-tvos', + 'flutter-android', + 'react-native-android', + 'flutter-windows', + 'flutter-linux', + ], + ]; + + parent::__construct(); + + $this + ->addRule('hostname', [ + 'type' => self::TYPE_STRING, + 'description' => 'Web app hostname. Empty string for other platforms.', + 'default' => '', + 'example' => 'app.example.com', + ]) + // Backwards compatibility + ->addRule('key', [ + 'hidden' => true, + 'type' => self::TYPE_STRING, + 'description' => 'Deprecated for old versions using alias endpoint to create universal platform.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Web'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_WEB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformWindows.php b/src/Appwrite/Utopia/Response/Model/PlatformWindows.php new file mode 100644 index 0000000000..20da977468 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformWindows.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_WINDOWS, + ]; + + parent::__construct(); + + $this + ->addRule('packageIdentifierName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Windows package identifier name.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Windows'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_WINDOWS; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'packageIdentifierName' + $document->setAttribute('packageIdentifierName', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index cd33a29685..1ef73aa769 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -9,11 +9,6 @@ use Utopia\Database\Document; class Project extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this @@ -137,6 +132,24 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authDisposableEmails', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to disallow disposable email addresses during signup and email updates.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authCanonicalEmails', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to require canonical email addresses during signup and email updates.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authFreeEmails', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to disallow free email addresses during signup and email updates.', + 'default' => false, + 'example' => true, + ]) ->addRule('authMockNumbers', [ 'type' => Response::MODEL_MOCK_NUMBER, 'description' => 'An array of mock numbers and their corresponding verification codes (OTPs).', @@ -182,7 +195,13 @@ class Project extends Model 'array' => true, ]) ->addRule('platforms', [ - 'type' => Response::MODEL_PLATFORM, + 'type' => [ + Response::MODEL_PLATFORM_WEB, + Response::MODEL_PLATFORM_APPLE, + Response::MODEL_PLATFORM_ANDROID, + Response::MODEL_PLATFORM_WINDOWS, + Response::MODEL_PLATFORM_LINUX, + ], 'description' => 'List of Platforms.', 'default' => [], 'example' => new \stdClass(), @@ -415,6 +434,9 @@ class Project extends Model $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); + $document->setAttribute('authDisposableEmails', $authValues['disposableEmails'] ?? false); + $document->setAttribute('authCanonicalEmails', $authValues['canonicalEmails'] ?? false); + $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index 476778e68b..01447ccfc2 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -157,9 +157,9 @@ class User extends Model } /** - * Get Collection + * Filter user document attributes for response output. * - * @return string + * @return Document */ public function filter(Document $document): Document { diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 517ad4807d..1ae8d5cb7b 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class Webhook extends Model { - /** - * @var bool - */ - protected bool $public = true; - public function __construct() { $this diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 758133c4c0..d170d56fe4 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -219,7 +219,8 @@ class Client curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders, &$cookies) { + curl_setopt($ch, CURLOPT_COOKIEFILE, ''); // enable in-memory RFC 6265 cookie engine + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { $len = strlen($header); $header = explode(':', $header, 2); @@ -227,12 +228,6 @@ class Client return $len; } - if (strtolower(trim($header[0])) == 'set-cookie') { - $parsed = $this->parseCookie((string)trim($header[1])); - $name = array_key_first($parsed); - $cookies[$name] = $parsed[$name]; - } - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); return $len; @@ -259,6 +254,11 @@ class Client $responseType = $responseHeaders['content-type'] ?? ''; $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); + foreach (curl_getinfo($ch, CURLINFO_COOKIELIST) as $line) { + $parts = explode("\t", $line); + $cookies[$parts[5]] = $parts[6] ?? ''; + } + if ($decode && $method !== self::METHOD_HEAD) { $strpos = strpos($responseType, ';'); $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; @@ -309,21 +309,6 @@ class Client ]; } - /** - * Parse Cookie String - * - * @param string $cookie - * @return array - */ - public function parseCookie(string $cookie): array - { - $cookies = []; - - parse_str(strtr($cookie, ['&' => '%26', '+' => '%2B', ';' => '&']), $cookies); - - return $cookies; - } - /** * Flatten params array to PHP multiple format * diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index eea53d9ea8..f6eb963967 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1324,8 +1324,8 @@ class UsageTest extends Scope $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); $this->validateDates($response['body']['requests']); // vectordbTotal should reflect only VectorsDB instances, not relational databases. - $this->assertEquals($vectordbTotal, $response['body']['vectordbDatabasesTotal']); - $this->assertEquals($documentsTotal, $response['body']['vectordbDocumentsTotal']); + $this->assertEquals($vectordbTotal, $response['body']['vectorsdbDatabasesTotal']); + $this->assertEquals($documentsTotal, $response['body']['vectorsdbDocumentsTotal']); }); $response = $this->client->call( diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index b7037267c5..10641019f0 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -164,7 +164,11 @@ trait ProjectCustom 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ], ]); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 107dceaa5e..951ab179b3 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -802,6 +802,16 @@ class AccountCustomClientTest extends Scope $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $accountResponse = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(200, $accountResponse['headers']['status-code']); + $this->assertEquals($email, $accountResponse['body']['email']); + // apiKey is only available in custom client test $apiKey = $this->getProject()['apiKey']; if (!empty($apiKey)) { @@ -4150,4 +4160,178 @@ class AccountCustomClientTest extends Scope $this->assertEquals(401, $verification3['headers']['status-code']); } + + /** + * Test that a new email/password session is immediately usable even when + * a concurrent request re-populates the user cache between the cache purge + * and session creation. + * + * Regression test for: purging the user cache BEFORE persisting the session + * allows a concurrent request (from a different Swoole worker) to re-cache + * a stale user document that lacks the new session, causing sessionVerify + * to fail with 401 on subsequent requests using the new session. + */ + public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void + { + $projectId = $this->getProject()['$id']; + $endpoint = $this->client->getEndpoint(); + + $email = uniqid('race_', true) . getmypid() . '@localhost.test'; + $password = 'password123!'; + + // Create user + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Race Test User', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Login to get session A + $responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertEquals(201, $responseA['headers']['status-code']); + $sessionA = $responseA['cookies']['a_session_' . $projectId]; + + // Verify session A works + $verifyA = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionA, + ]); + $this->assertEquals(200, $verifyA['headers']['status-code']); + + /** + * Race condition scenario: + * 1. Start login B via curl_multi (non-blocking) + * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument + * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead) + * 3. THEN add GET requests to curl_multi - these hit different workers and + * re-cache a stale user document (without session B) during the window + * between purgeCachedDocument and createDocument + * 4. After all complete, verify session B is usable + */ + for ($attempt = 0; $attempt < 5; $attempt++) { + $loginCookies = []; + + $multi = curl_multi_init(); + + // Start login B first (alone) + $loginHandle = curl_init("{$endpoint}/account/sessions/email"); + curl_setopt_array($loginHandle, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'origin: http://localhost', + 'content-type: application/json', + "x-appwrite-project: {$projectId}", + ], + CURLOPT_POSTFIELDS => \json_encode([ + 'email' => $email, + 'password' => $password, + ]), + CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) { + if (\stripos($header, 'set-cookie:') === 0) { + $cookiePart = \trim(\substr($header, 11)); + $eqPos = \strpos($cookiePart, '='); + if ($eqPos !== false) { + $name = \substr($cookiePart, 0, $eqPos); + $rest = \substr($cookiePart, $eqPos + 1); + $semiPos = \strpos($rest, ';'); + $loginCookies[$name] = $semiPos !== false + ? \substr($rest, 0, $semiPos) + : $rest; + } + } + return \strlen($header); + }, + ]); + curl_multi_add_handle($multi, $loginHandle); + + // Drive the login transfer forward and wait for the server to start + // processing the login (past hash verification + cache purge). + $deadline = \microtime(true) + 0.15; // 150ms + do { + curl_multi_exec($multi, $active); + curl_multi_select($multi, 0.005); + } while (\microtime(true) < $deadline && $active); + + // NOW add GET requests - they arrive after the cache purge + // but before session creation (which is delayed by the usleep or I/O). + $getHandles = []; + for ($i = 0; $i < 10; $i++) { + $gh = curl_init("{$endpoint}/account"); + curl_setopt_array($gh, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'origin: http://localhost', + 'content-type: application/json', + "x-appwrite-project: {$projectId}", + "cookie: a_session_{$projectId}={$sessionA}", + ], + ]); + curl_multi_add_handle($multi, $gh); + $getHandles[] = $gh; + } + + // Drive all to completion + do { + $status = curl_multi_exec($multi, $active); + if ($active) { + curl_multi_select($multi, 0.05); + } + } while ($active && $status === CURLM_OK); + + $loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE); + + curl_multi_remove_handle($multi, $loginHandle); + curl_close($loginHandle); + foreach ($getHandles as $gh) { + curl_multi_remove_handle($multi, $gh); + curl_close($gh); + } + curl_multi_close($multi); + + $this->assertEquals(201, $loginStatus, 'Login for session B should succeed'); + + $sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null; + $this->assertNotNull($sessionBCookie, 'Session B cookie should be set'); + + // THE CRITICAL CHECK: verify session B is usable immediately + $verifyB = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => "a_session_{$projectId}={$sessionBCookie}", + ]); + + $this->assertEquals( + 200, + $verifyB['headers']['status-code'], + 'Session B must be immediately usable after login. ' + . 'A 401 here means a stale user cache (without the new session) was served. ' + . 'The fix is to create the session document BEFORE purging the user cache.' + ); + + // Clean up session B for next iteration + $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => "a_session_{$projectId}={$sessionBCookie}", + ]); + } + } } diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 3b44d5c1e3..2c5e587fc2 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3527,6 +3527,157 @@ trait DatabasesBase $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); } + public function testListDocumentsCachedWithoutSelectQuery(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + $docIds = $data['documentIds']; + + // No Query::select(...) at all — ttl alone should enable caching. + $queries = [ + Query::equal('$id', $docIds)->toString(), + Query::orderAsc('releaseYear')->toString(), + ]; + + // 1. First request populates the cache. + $documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 60, + ]); + + $this->assertEquals(200, $documents1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']); + $this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']); + + // 2. Same request hits cache — proves the gate is ttl > 0, not the presence of a select query. + $documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 60, + ]); + + $this->assertEquals(200, $documents2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']); + $this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']); + $this->assertSame( + $documents1['body'][$this->getRecordResource()], + $documents2['body'][$this->getRecordResource()] + ); + } + + public function testListDocumentsCachePurgedByUpdate(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + $docIds = $data['documentIds']; + + // Use different select queries from other cache tests to avoid cache key collision. + $queries = [ + Query::equal('$id', $docIds)->toString(), + Query::select(['title', 'tagline', '$id'])->toString(), + Query::orderAsc('$createdAt')->toString(), + ]; + + // 1. First request populates the cache. + $documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents1['headers']['status-code']); + $this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']); + + // 2. Same request hits cache. + $documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents2['headers']['status-code']); + $this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']); + + // 3. Update the collection/table with purge=true to invalidate all cached list responses. + $update = $this->client->call(Client::METHOD_PUT, $this->getContainerUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'name' => 'Movies', + 'enabled' => true, + $this->getSecurityParam() => true, + 'purge' => true, + ]); + + $this->assertEquals(200, $update['headers']['status-code']); + + // 4. Same request should now miss cache because purge=true cleared the hash. + $documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents3['headers']['status-code']); + $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); + + // 5. Re-reading without purge should hit the freshly populated cache. + $documents4 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents4['headers']['status-code']); + $this->assertEquals('hit', $documents4['headers']['x-appwrite-cache']); + + // 6. Update without purge=true must NOT invalidate the cache. + $update2 = $this->client->call(Client::METHOD_PUT, $this->getContainerUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'name' => 'Movies', + 'enabled' => true, + $this->getSecurityParam() => true, + ]); + + $this->assertEquals(200, $update2['headers']['status-code']); + + $documents5 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents5['headers']['status-code']); + $this->assertEquals('hit', $documents5['headers']['x-appwrite-cache']); + } + public function testGetDocument(): void { $data = $this->getDocumentsList(); @@ -3631,7 +3782,7 @@ trait DatabasesBase ]); $adapter = getenv('_APP_DB_ADAPTER'); - if ($adapter === 'mongodb') { + if ($adapter === 'mongodb' || !$this->getSupportForAttributes()) { $this->assertEquals(400, $response['headers']['status-code']); } else { $this->assertEquals(200, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php b/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php index f68f0b909e..06c008e61d 100644 --- a/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php +++ b/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php @@ -29,8 +29,8 @@ class DatabasesStringTypesTest extends Scope protected function setupDatabaseAndCollection(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$setupCache[$cacheKey])) { - return static::$setupCache[$cacheKey]; + if (!empty(self::$setupCache[$cacheKey])) { + return self::$setupCache[$cacheKey]; } $projectId = $this->getProject()['$id']; @@ -135,12 +135,12 @@ class DatabasesStringTypesTest extends Scope // Wait for all attributes to be available $this->waitForAllAttributes($databaseId, $collectionId); - static::$setupCache[$cacheKey] = [ + self::$setupCache[$cacheKey] = [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, ]; - return static::$setupCache[$cacheKey]; + return self::$setupCache[$cacheKey]; } public function testCreateDatabase(): void diff --git a/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php b/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php index d6869cc650..134257d06a 100644 --- a/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php +++ b/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php @@ -45,6 +45,12 @@ trait DatabasesPermissionsBase return $recordId ? "{$base}/{$recordId}" : $base; } + protected function getIndexUrl(string $databaseId, string $containerId, string $indexId = ''): string + { + $base = "{$this->getContainerUrl($databaseId, $containerId)}/indexes"; + return $indexId ? "{$base}/{$indexId}" : $base; + } + // User Management Methods public function createUser(string $id, string $email, string $password = 'test123!'): array { diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index af426d5221..42976cda84 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -100,6 +100,24 @@ trait FunctionsBase 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertNotEquals(401, $function['headers']['status-code'], 'Auth failed while polling function activation'); + + if ( + ($function['body']['deploymentId'] ?? '') !== $deploymentId + && ($function['body']['latestDeploymentId'] ?? '') === $deploymentId + && ($function['body']['latestDeploymentStatus'] ?? '') === 'ready' + ) { + $activation = $this->updateFunctionDeployment($functionId, $deploymentId); + $this->assertContains( + $activation['headers']['status-code'], + [200, 409], + 'Deployment activation request failed: ' . json_encode($activation['body'], JSON_PRETTY_PRINT) + ); + + if ($activation['headers']['status-code'] === 200) { + $function = $activation; + } + } + $this->assertEquals($deploymentId, $function['body']['deploymentId'] ?? '', 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT)); }, 120000, 500); } diff --git a/tests/e2e/Services/Functions/FunctionsCustomClientTest.php b/tests/e2e/Services/Functions/FunctionsCustomClientTest.php index 1d61cb0ebb..1208121077 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomClientTest.php @@ -414,7 +414,7 @@ class FunctionsCustomClientTest extends Scope 'offset' => 2 ]); $this->assertEquals(200, $templatesOffset['headers']['status-code']); - $this->addToAssertionCount(1, $templatesOffset['body']['templates']); + $this->addToAssertionCount(1); $this->assertEquals($templates['body']['templates'][2]['id'], $templatesOffset['body']['templates'][0]['id']); // List templates with filters diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index d0b2190f1c..ba518ee0b6 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1003,6 +1003,7 @@ class FunctionsCustomServerTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'] ]; $id = ''; + $largeTag = null; while (!feof($handle)) { $curlFile = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(@fread($handle, $chunkSize)), $mimeType, 'large-fx.tar.gz'); $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size - 1) . '/' . $size; diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index 234d8fa71b..8dc2fe337f 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -24,8 +24,8 @@ class FunctionsClientTest extends Scope protected function setupFunction(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFunction[$key])) { - return static::$cachedFunction[$key]; + if (!empty(self::$cachedFunction[$key])) { + return self::$cachedFunction[$key]; } $projectId = $this->getProject()['$id']; @@ -79,15 +79,15 @@ class FunctionsClientTest extends Scope $this->assertIsArray($variables['body']['data']); $this->assertArrayNotHasKey('errors', $variables['body']); - static::$cachedFunction[$key] = $function; + self::$cachedFunction[$key] = $function; return $function; } protected function setupDeployment(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedDeployment[$key])) { - return static::$cachedDeployment[$key]; + if (!empty(self::$cachedDeployment[$key])) { + return self::$cachedDeployment[$key]; } $function = $this->setupFunction(); @@ -146,15 +146,15 @@ class FunctionsClientTest extends Scope $this->assertEquals('ready', $deployment['status']); }, 60000); - static::$cachedDeployment[$key] = $deployment; + self::$cachedDeployment[$key] = $deployment; return $deployment; } protected function setupExecution(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedExecution[$key])) { - return static::$cachedExecution[$key]; + if (!empty(self::$cachedExecution[$key])) { + return self::$cachedExecution[$key]; } $function = $this->setupFunction(); @@ -177,8 +177,8 @@ class FunctionsClientTest extends Scope $this->assertIsArray($execution['body']['data']); $this->assertArrayNotHasKey('errors', $execution['body']); - static::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; - return static::$cachedExecution[$key]; + self::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; + return self::$cachedExecution[$key]; } public function testCreateFunction(): void diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index a66789d646..8e1c7ac7e7 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -25,8 +25,8 @@ class FunctionsServerTest extends Scope protected function setupFunction(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFunction[$key])) { - return static::$cachedFunction[$key]; + if (!empty(self::$cachedFunction[$key])) { + return self::$cachedFunction[$key]; } $projectId = $this->getProject()['$id']; @@ -79,15 +79,15 @@ class FunctionsServerTest extends Scope $this->assertIsArray($variables['body']['data']); $this->assertArrayNotHasKey('errors', $variables['body']); - static::$cachedFunction[$key] = $function; + self::$cachedFunction[$key] = $function; return $function; } protected function setupDeployment(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedDeployment[$key])) { - return static::$cachedDeployment[$key]; + if (!empty(self::$cachedDeployment[$key])) { + return self::$cachedDeployment[$key]; } $function = $this->setupFunction(); @@ -149,15 +149,15 @@ class FunctionsServerTest extends Scope $this->assertEquals('ready', $deployment['status']); }, 120000); - static::$cachedDeployment[$key] = $deployment; + self::$cachedDeployment[$key] = $deployment; return $deployment; } protected function setupExecution(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedExecution[$key])) { - return static::$cachedExecution[$key]; + if (!empty(self::$cachedExecution[$key])) { + return self::$cachedExecution[$key]; } $deployment = $this->setupDeployment(); @@ -179,8 +179,8 @@ class FunctionsServerTest extends Scope $this->assertIsArray($execution['body']['data']); $this->assertArrayNotHasKey('errors', $execution['body']); - static::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; - return static::$cachedExecution[$key]; + self::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; + return self::$cachedExecution[$key]; } public function testCreateFunction(): void @@ -496,8 +496,8 @@ class FunctionsServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedDeployment[$key] = []; - static::$cachedExecution[$key] = []; + self::$cachedDeployment[$key] = []; + self::$cachedExecution[$key] = []; } /** @@ -529,6 +529,6 @@ class FunctionsServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedFunction[$key] = []; + self::$cachedFunction[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/Legacy/AbuseTest.php b/tests/e2e/Services/GraphQL/Legacy/AbuseTest.php index dfec046f55..39939fdc0e 100644 --- a/tests/e2e/Services/GraphQL/Legacy/AbuseTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/AbuseTest.php @@ -102,7 +102,7 @@ class AbuseTest extends Scope $maxQueries = System::getEnv('_APP_GRAPHQL_MAX_QUERIES', 10); $query = []; - for ($i = 0; $i <= $maxQueries + 1; $i++) { + for ($i = 0; $i <= ((int) $maxQueries) + 1; $i++) { $query[] = ['query' => $this->getQuery(self::LIST_COUNTRIES)]; } diff --git a/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php b/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php index 4d34dc6b23..a4987c4078 100644 --- a/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php @@ -43,8 +43,8 @@ class DatabaseClientTest extends Scope protected function setupDatabase(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$database[$cacheKey])) { - return static::$database[$cacheKey]; + if (!empty(self::$database[$cacheKey])) { + return self::$database[$cacheKey]; } $projectId = $this->getProject()['$id']; @@ -71,9 +71,9 @@ class DatabaseClientTest extends Scope } $this->assertIsArray($database['body']['data']); - static::$database[$cacheKey] = $database['body']['data']['databasesCreate']; + self::$database[$cacheKey] = $database['body']['data']['databasesCreate']; - return static::$database[$cacheKey]; + return self::$database[$cacheKey]; } /** @@ -82,8 +82,8 @@ class DatabaseClientTest extends Scope protected function setupCollection(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$collection[$cacheKey])) { - return static::$collection[$cacheKey]; + if (!empty(self::$collection[$cacheKey])) { + return self::$collection[$cacheKey]; } $database = $this->setupDatabase(); @@ -121,12 +121,12 @@ class DatabaseClientTest extends Scope $this->assertIsArray($collection['body']['data']); - static::$collection[$cacheKey] = [ + self::$collection[$cacheKey] = [ 'database' => $database, 'collection' => $collection['body']['data']['databasesCreateCollection'], ]; - return static::$collection[$cacheKey]; + return self::$collection[$cacheKey]; } /** @@ -206,8 +206,8 @@ class DatabaseClientTest extends Scope protected function setupDocument(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$document[$cacheKey])) { - return static::$document[$cacheKey]; + if (!empty(self::$document[$cacheKey])) { + return self::$document[$cacheKey]; } $data = $this->setupAttributes(); @@ -256,13 +256,13 @@ class DatabaseClientTest extends Scope $this->assertArrayNotHasKey('errors', $document['body']); $this->assertIsArray($document['body']['data']); - static::$document[$cacheKey] = [ + self::$document[$cacheKey] = [ 'database' => $data['database'], 'collection' => $data['collection'], 'document' => $document['body']['data']['databasesCreateDocument'], ]; - return static::$document[$cacheKey]; + return self::$document[$cacheKey]; } /** @@ -271,8 +271,8 @@ class DatabaseClientTest extends Scope protected function setupBulkData(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$bulkData[$cacheKey])) { - return static::$bulkData[$cacheKey]; + if (!empty(self::$bulkData[$cacheKey])) { + return self::$bulkData[$cacheKey]; } $project = $this->getProject(); @@ -352,13 +352,13 @@ class DatabaseClientTest extends Scope $this->assertArrayNotHasKey('errors', $res['body']); $this->assertCount(10, $res['body']['data']['databasesCreateDocuments']['documents']); - static::$bulkData[$cacheKey] = [ + self::$bulkData[$cacheKey] = [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'projectId' => $projectId, ]; - return static::$bulkData[$cacheKey]; + return self::$bulkData[$cacheKey]; } /** diff --git a/tests/e2e/Services/GraphQL/MessagingTest.php b/tests/e2e/Services/GraphQL/MessagingTest.php index 322c51c1f7..c76b9a2e4d 100644 --- a/tests/e2e/Services/GraphQL/MessagingTest.php +++ b/tests/e2e/Services/GraphQL/MessagingTest.php @@ -26,8 +26,8 @@ class MessagingTest extends Scope protected function setupProviders(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedProviders[$key])) { - return static::$cachedProviders[$key]; + if (!empty(self::$cachedProviders[$key])) { + return self::$cachedProviders[$key]; } $providersParams = [ @@ -128,15 +128,15 @@ class MessagingTest extends Scope $this->assertEquals($providersParams[$providerKey]['name'], $response['body']['data']['messagingCreate' . $providerKey . 'Provider']['name']); } - static::$cachedProviders[$key] = $providers; + self::$cachedProviders[$key] = $providers; return $providers; } protected function setupUpdatedProviders(): array { $key = $this->getProject()['$id'] . '_updated'; - if (!empty(static::$cachedProviders[$key])) { - return static::$cachedProviders[$key]; + if (!empty(self::$cachedProviders[$key])) { + return self::$cachedProviders[$key]; } $providers = $this->setupProviders(); @@ -247,15 +247,15 @@ class MessagingTest extends Scope $this->assertEquals('Mailgun2', $response['body']['data']['messagingUpdateMailgunProvider']['name']); $this->assertEquals(false, $response['body']['data']['messagingUpdateMailgunProvider']['enabled']); - static::$cachedProviders[$key] = $providers; + self::$cachedProviders[$key] = $providers; return $providers; } protected function setupTopic(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTopic[$key])) { - return static::$cachedTopic[$key]; + if (!empty(self::$cachedTopic[$key])) { + return self::$cachedTopic[$key]; } $query = $this->getQuery(self::CREATE_TOPIC); @@ -275,15 +275,15 @@ class MessagingTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('topic1', $response['body']['data']['messagingCreateTopic']['name']); - static::$cachedTopic[$key] = $response['body']['data']['messagingCreateTopic']; - return static::$cachedTopic[$key]; + self::$cachedTopic[$key] = $response['body']['data']['messagingCreateTopic']; + return self::$cachedTopic[$key]; } protected function setupUpdatedTopic(): string { $key = $this->getProject()['$id'] . '_updated'; - if (!empty(static::$cachedTopic[$key])) { - return static::$cachedTopic[$key]; + if (!empty(self::$cachedTopic[$key])) { + return self::$cachedTopic[$key]; } $topic = $this->setupTopic(); @@ -306,15 +306,15 @@ class MessagingTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('topic2', $response['body']['data']['messagingUpdateTopic']['name']); - static::$cachedTopic[$key] = $topicId; + self::$cachedTopic[$key] = $topicId; return $topicId; } protected function setupSubscriber(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedSubscriber[$key])) { - return static::$cachedSubscriber[$key]; + if (!empty(self::$cachedSubscriber[$key])) { + return self::$cachedSubscriber[$key]; } $topic = $this->setupTopic(); @@ -386,15 +386,15 @@ class MessagingTest extends Scope $this->assertEquals($response['body']['data']['messagingCreateSubscriber']['targetId'], $targetId); $this->assertEquals($response['body']['data']['messagingCreateSubscriber']['target']['userId'], $userId); - static::$cachedSubscriber[$key] = $response['body']['data']['messagingCreateSubscriber']; - return static::$cachedSubscriber[$key]; + self::$cachedSubscriber[$key] = $response['body']['data']['messagingCreateSubscriber']; + return self::$cachedSubscriber[$key]; } protected function setupEmail(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedEmail[$key])) { - return static::$cachedEmail[$key]; + if (!empty(self::$cachedEmail[$key])) { + return self::$cachedEmail[$key]; } if (empty(System::getEnv('_APP_MESSAGE_EMAIL_TEST_DSN'))) { @@ -409,7 +409,7 @@ class MessagingTest extends Scope $apiKey = $emailDSN->getPassword(); $domain = $emailDSN->getUser(); - if (empty($to) || empty($from) || empty($apiKey) || empty($domain) || empty($isEuRegion)) { + if (empty($to) || empty($fromName) || empty($fromEmail) || empty($apiKey) || empty($domain) || empty($isEuRegion)) { $this->markTestSkipped('Email provider not configured'); } @@ -550,15 +550,15 @@ class MessagingTest extends Scope $this->assertEquals(1, $message['body']['data']['messagingGetMessage']['deliveredTotal']); $this->assertEquals(0, \count($message['body']['data']['messagingGetMessage']['deliveryErrors'])); - static::$cachedEmail[$key] = $message['body']['data']['messagingGetMessage']; - return static::$cachedEmail[$key]; + self::$cachedEmail[$key] = $message['body']['data']['messagingGetMessage']; + return self::$cachedEmail[$key]; } protected function setupSms(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedSms[$key])) { - return static::$cachedSms[$key]; + if (!empty(self::$cachedSms[$key])) { + return self::$cachedSms[$key]; } if (empty(System::getEnv('_APP_MESSAGE_SMS_TEST_DSN'))) { @@ -709,15 +709,15 @@ class MessagingTest extends Scope $this->assertEquals(1, $message['body']['data']['messagingGetMessage']['deliveredTotal']); $this->assertEquals(0, \count($message['body']['data']['messagingGetMessage']['deliveryErrors'])); - static::$cachedSms[$key] = $message['body']['data']['messagingGetMessage']; - return static::$cachedSms[$key]; + self::$cachedSms[$key] = $message['body']['data']['messagingGetMessage']; + return self::$cachedSms[$key]; } protected function setupPush(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedPush[$key])) { - return static::$cachedPush[$key]; + if (!empty(self::$cachedPush[$key])) { + return self::$cachedPush[$key]; } if (empty(System::getEnv('_APP_MESSAGE_PUSH_TEST_DSN'))) { @@ -870,8 +870,8 @@ class MessagingTest extends Scope $this->assertEquals(1, $message['body']['data']['messagingGetMessage']['deliveredTotal']); $this->assertEquals(0, \count($message['body']['data']['messagingGetMessage']['deliveryErrors'])); - static::$cachedPush[$key] = $message['body']['data']['messagingGetMessage']; - return static::$cachedPush[$key]; + self::$cachedPush[$key] = $message['body']['data']['messagingGetMessage']; + return self::$cachedPush[$key]; } public function testCreateProviders(): void @@ -945,8 +945,8 @@ class MessagingTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedProviders[$key] = []; - static::$cachedProviders[$key . '_updated'] = []; + self::$cachedProviders[$key] = []; + self::$cachedProviders[$key . '_updated'] = []; } public function testCreateTopic(): void @@ -1081,7 +1081,7 @@ class MessagingTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedSubscriber[$key] = []; + self::$cachedSubscriber[$key] = []; } public function testDeleteTopic() @@ -1105,8 +1105,8 @@ class MessagingTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedTopic[$key] = []; - static::$cachedTopic[$key . '_updated'] = []; + self::$cachedTopic[$key] = []; + self::$cachedTopic[$key . '_updated'] = []; } public function testSendEmail(): void diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index 84af910a50..25041e843b 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -23,8 +23,8 @@ class StorageClientTest extends Scope protected function setupBucket(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedBucket[$key])) { - return static::$cachedBucket[$key]; + if (!empty(self::$cachedBucket[$key])) { + return self::$cachedBucket[$key]; } $projectId = $this->getProject()['$id']; @@ -55,15 +55,15 @@ class StorageClientTest extends Scope $bucket = $bucket['body']['data']['storageCreateBucket']; $this->assertEquals('Actors', $bucket['name']); - static::$cachedBucket[$key] = $bucket; + self::$cachedBucket[$key] = $bucket; return $bucket; } protected function setupFile(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFile[$key])) { - return static::$cachedFile[$key]; + if (!empty(self::$cachedFile[$key])) { + return self::$cachedFile[$key]; } $bucket = $this->setupBucket(); @@ -99,8 +99,8 @@ class StorageClientTest extends Scope $this->assertIsArray($file['body']['data']); $this->assertArrayNotHasKey('errors', $file['body']); - static::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; - return static::$cachedFile[$key]; + self::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; + return self::$cachedFile[$key]; } public function testCreateBucket(): void @@ -229,6 +229,8 @@ class StorageClientTest extends Scope ], $this->getHeaders()), $gqlPayload); $this->assertEquals(47218, \strlen($file['body'])); + + return $file; } /** @@ -319,6 +321,6 @@ class StorageClientTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedFile[$key] = []; + self::$cachedFile[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index 8a3158a98b..cc4c8ecec3 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -23,8 +23,8 @@ class StorageServerTest extends Scope protected function setupBucket(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedBucket[$key])) { - return static::$cachedBucket[$key]; + if (!empty(self::$cachedBucket[$key])) { + return self::$cachedBucket[$key]; } $projectId = $this->getProject()['$id']; @@ -54,15 +54,15 @@ class StorageServerTest extends Scope $bucket = $bucket['body']['data']['storageCreateBucket']; $this->assertEquals('Actors', $bucket['name']); - static::$cachedBucket[$key] = $bucket; + self::$cachedBucket[$key] = $bucket; return $bucket; } protected function setupFile(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFile[$key])) { - return static::$cachedFile[$key]; + if (!empty(self::$cachedFile[$key])) { + return self::$cachedFile[$key]; } $bucket = $this->setupBucket(); @@ -98,8 +98,8 @@ class StorageServerTest extends Scope $this->assertIsArray($file['body']['data']); $this->assertArrayNotHasKey('errors', $file['body']); - static::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; - return static::$cachedFile[$key]; + self::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; + return self::$cachedFile[$key]; } public function testCreateBucket(): void @@ -291,6 +291,8 @@ class StorageServerTest extends Scope ], $this->getHeaders()), $gqlPayload); $this->assertEquals(47218, \strlen($file['body'])); + + return $file; } /** @@ -413,7 +415,7 @@ class StorageServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedFile[$key] = []; + self::$cachedFile[$key] = []; } /** @@ -443,7 +445,7 @@ class StorageServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedBucket[$key] = []; - static::$cachedFile[$key] = []; + self::$cachedBucket[$key] = []; + self::$cachedFile[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php b/tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php index 05185149fd..c358882b8b 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php @@ -102,7 +102,7 @@ class AbuseTest extends Scope $maxQueries = System::getEnv('_APP_GRAPHQL_MAX_QUERIES', 10); $query = []; - for ($i = 0; $i <= $maxQueries + 1; $i++) { + for ($i = 0; $i <= ((int) $maxQueries) + 1; $i++) { $query[] = ['query' => $this->getQuery(self::LIST_COUNTRIES)]; } diff --git a/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php b/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php index 3078202546..f0b4e4b75c 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php @@ -39,8 +39,8 @@ class DatabaseServerTest extends Scope protected function setupDatabase(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedDatabase[$cacheKey])) { - return static::$cachedDatabase[$cacheKey]; + if (!empty(self::$cachedDatabase[$cacheKey])) { + return self::$cachedDatabase[$cacheKey]; } $projectId = $this->getProject()['$id']; @@ -62,15 +62,15 @@ class DatabaseServerTest extends Scope $this->assertArrayNotHasKey('errors', $database['body']); - static::$cachedDatabase[$cacheKey] = $database['body']['data']['tablesDBCreate']; - return static::$cachedDatabase[$cacheKey]; + self::$cachedDatabase[$cacheKey] = $database['body']['data']['tablesDBCreate']; + return self::$cachedDatabase[$cacheKey]; } protected function setupTable(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedTableData[$cacheKey])) { - return static::$cachedTableData[$cacheKey]; + if (!empty(self::$cachedTableData[$cacheKey])) { + return self::$cachedTableData[$cacheKey]; } $database = $this->setupDatabase(); @@ -124,20 +124,20 @@ class DatabaseServerTest extends Scope $this->assertArrayNotHasKey('errors', $table2['body']); $table2 = $table2['body']['data']['tablesDBCreateTable']; - static::$cachedTableData[$cacheKey] = [ + self::$cachedTableData[$cacheKey] = [ 'database' => $database, 'table' => $table, 'table2' => $table2, ]; - return static::$cachedTableData[$cacheKey]; + return self::$cachedTableData[$cacheKey]; } protected function setupStringColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedStringColumnData[$cacheKey])) { - return static::$cachedStringColumnData[$cacheKey]; + if (!empty(self::$cachedStringColumnData[$cacheKey])) { + return self::$cachedStringColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -159,8 +159,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedStringColumnData[$cacheKey] = $data; - return static::$cachedStringColumnData[$cacheKey]; + self::$cachedStringColumnData[$cacheKey] = $data; + return self::$cachedStringColumnData[$cacheKey]; } protected function setupUpdatedStringColumn(): array @@ -205,8 +205,8 @@ class DatabaseServerTest extends Scope protected function setupIntegerColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedIntegerColumnData[$cacheKey])) { - return static::$cachedIntegerColumnData[$cacheKey]; + if (!empty(self::$cachedIntegerColumnData[$cacheKey])) { + return self::$cachedIntegerColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -229,8 +229,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedIntegerColumnData[$cacheKey] = $data; - return static::$cachedIntegerColumnData[$cacheKey]; + self::$cachedIntegerColumnData[$cacheKey] = $data; + return self::$cachedIntegerColumnData[$cacheKey]; } protected function setupUpdatedIntegerColumn(): array @@ -275,8 +275,8 @@ class DatabaseServerTest extends Scope protected function setupBooleanColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedBooleanColumnData[$cacheKey])) { - return static::$cachedBooleanColumnData[$cacheKey]; + if (!empty(self::$cachedBooleanColumnData[$cacheKey])) { + return self::$cachedBooleanColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -297,8 +297,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedBooleanColumnData[$cacheKey] = $data; - return static::$cachedBooleanColumnData[$cacheKey]; + self::$cachedBooleanColumnData[$cacheKey] = $data; + return self::$cachedBooleanColumnData[$cacheKey]; } protected function setupUpdatedBooleanColumn(): array @@ -341,8 +341,8 @@ class DatabaseServerTest extends Scope protected function setupFloatColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedFloatColumnData[$cacheKey])) { - return static::$cachedFloatColumnData[$cacheKey]; + if (!empty(self::$cachedFloatColumnData[$cacheKey])) { + return self::$cachedFloatColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -366,8 +366,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedFloatColumnData[$cacheKey] = $data; - return static::$cachedFloatColumnData[$cacheKey]; + self::$cachedFloatColumnData[$cacheKey] = $data; + return self::$cachedFloatColumnData[$cacheKey]; } protected function setupUpdatedFloatColumn(): array @@ -412,8 +412,8 @@ class DatabaseServerTest extends Scope protected function setupEmailColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedEmailColumnData[$cacheKey])) { - return static::$cachedEmailColumnData[$cacheKey]; + if (!empty(self::$cachedEmailColumnData[$cacheKey])) { + return self::$cachedEmailColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -434,8 +434,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedEmailColumnData[$cacheKey] = $data; - return static::$cachedEmailColumnData[$cacheKey]; + self::$cachedEmailColumnData[$cacheKey] = $data; + return self::$cachedEmailColumnData[$cacheKey]; } protected function setupUpdatedEmailColumn(): array @@ -478,8 +478,8 @@ class DatabaseServerTest extends Scope protected function setupEnumColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedEnumColumnData[$cacheKey])) { - return static::$cachedEnumColumnData[$cacheKey]; + if (!empty(self::$cachedEnumColumnData[$cacheKey])) { + return self::$cachedEnumColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -505,8 +505,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedEnumColumnData[$cacheKey] = $data; - return static::$cachedEnumColumnData[$cacheKey]; + self::$cachedEnumColumnData[$cacheKey] = $data; + return self::$cachedEnumColumnData[$cacheKey]; } protected function setupUpdatedEnumColumn(): array @@ -554,8 +554,8 @@ class DatabaseServerTest extends Scope protected function setupDatetimeColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedDatetimeColumnData[$cacheKey])) { - return static::$cachedDatetimeColumnData[$cacheKey]; + if (!empty(self::$cachedDatetimeColumnData[$cacheKey])) { + return self::$cachedDatetimeColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -576,8 +576,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedDatetimeColumnData[$cacheKey] = $data; - return static::$cachedDatetimeColumnData[$cacheKey]; + self::$cachedDatetimeColumnData[$cacheKey] = $data; + return self::$cachedDatetimeColumnData[$cacheKey]; } protected function setupUpdatedDatetimeColumn(): array @@ -620,8 +620,8 @@ class DatabaseServerTest extends Scope protected function setupRelationshipColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedRelationshipColumnData[$cacheKey])) { - return static::$cachedRelationshipColumnData[$cacheKey]; + if (!empty(self::$cachedRelationshipColumnData[$cacheKey])) { + return self::$cachedRelationshipColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -645,8 +645,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedRelationshipColumnData[$cacheKey] = $data; - return static::$cachedRelationshipColumnData[$cacheKey]; + self::$cachedRelationshipColumnData[$cacheKey] = $data; + return self::$cachedRelationshipColumnData[$cacheKey]; } protected function setupUpdatedRelationshipColumn(): array @@ -688,8 +688,8 @@ class DatabaseServerTest extends Scope protected function setupIPColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedIPColumnData[$cacheKey])) { - return static::$cachedIPColumnData[$cacheKey]; + if (!empty(self::$cachedIPColumnData[$cacheKey])) { + return self::$cachedIPColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -711,8 +711,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedIPColumnData[$cacheKey] = $data; - return static::$cachedIPColumnData[$cacheKey]; + self::$cachedIPColumnData[$cacheKey] = $data; + return self::$cachedIPColumnData[$cacheKey]; } protected function setupUpdatedIPColumn(): array @@ -755,8 +755,8 @@ class DatabaseServerTest extends Scope protected function setupURLColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedURLColumnData[$cacheKey])) { - return static::$cachedURLColumnData[$cacheKey]; + if (!empty(self::$cachedURLColumnData[$cacheKey])) { + return self::$cachedURLColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -778,8 +778,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedURLColumnData[$cacheKey] = $data; - return static::$cachedURLColumnData[$cacheKey]; + self::$cachedURLColumnData[$cacheKey] = $data; + return self::$cachedURLColumnData[$cacheKey]; } protected function setupUpdatedURLColumn(): array @@ -822,8 +822,8 @@ class DatabaseServerTest extends Scope protected function setupIndex(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedIndexData[$cacheKey])) { - return static::$cachedIndexData[$cacheKey]; + if (!empty(self::$cachedIndexData[$cacheKey])) { + return self::$cachedIndexData[$cacheKey]; } // Need updated string and integer columns first @@ -855,31 +855,31 @@ class DatabaseServerTest extends Scope if (isset($index['body']['errors'])) { $errorMessage = $index['body']['errors'][0]['message'] ?? ''; if (strpos($errorMessage, 'already exists') !== false || strpos($errorMessage, 'Document with the requested ID already exists') !== false) { - static::$cachedIndexData[$cacheKey] = [ + self::$cachedIndexData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'index' => ['key' => 'index'], ]; - return static::$cachedIndexData[$cacheKey]; + return self::$cachedIndexData[$cacheKey]; } } $this->assertArrayNotHasKey('errors', $index['body']); - static::$cachedIndexData[$cacheKey] = [ + self::$cachedIndexData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'index' => $index['body']['data']['tablesDBCreateIndex'], ]; - return static::$cachedIndexData[$cacheKey]; + return self::$cachedIndexData[$cacheKey]; } protected function setupRow(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedRowData[$cacheKey])) { - return static::$cachedRowData[$cacheKey]; + if (!empty(self::$cachedRowData[$cacheKey])) { + return self::$cachedRowData[$cacheKey]; } // Need all columns that the row data references @@ -940,20 +940,20 @@ class DatabaseServerTest extends Scope $this->assertArrayNotHasKey('errors', $row['body']); $row = $row['body']['data']['tablesDBCreateRow']; - static::$cachedRowData[$cacheKey] = [ + self::$cachedRowData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'row' => $row, ]; - return static::$cachedRowData[$cacheKey]; + return self::$cachedRowData[$cacheKey]; } protected function setupBulkData(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedBulkData[$cacheKey])) { - return static::$cachedBulkData[$cacheKey]; + if (!empty(self::$cachedBulkData[$cacheKey])) { + return self::$cachedBulkData[$cacheKey]; } $project = $this->getProject(); @@ -1034,9 +1034,9 @@ class DatabaseServerTest extends Scope $this->client->call(Client::METHOD_POST, '/graphql', $headers, $payload); - static::$cachedBulkData[$cacheKey] = compact('databaseId', 'tableId', 'projectId'); + self::$cachedBulkData[$cacheKey] = compact('databaseId', 'tableId', 'projectId'); - return static::$cachedBulkData[$cacheKey]; + return self::$cachedBulkData[$cacheKey]; } public function testCreateDatabase(): void @@ -1088,7 +1088,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedStringColumnData[$cacheKey] = $data; + self::$cachedStringColumnData[$cacheKey] = $data; } /** @@ -1166,7 +1166,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedIntegerColumnData[$cacheKey] = $data; + self::$cachedIntegerColumnData[$cacheKey] = $data; } /** @@ -1246,7 +1246,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedBooleanColumnData[$cacheKey] = $data; + self::$cachedBooleanColumnData[$cacheKey] = $data; } /** @@ -1325,7 +1325,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedFloatColumnData[$cacheKey] = $data; + self::$cachedFloatColumnData[$cacheKey] = $data; } /** @@ -1405,7 +1405,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedEmailColumnData[$cacheKey] = $data; + self::$cachedEmailColumnData[$cacheKey] = $data; } /** @@ -1486,7 +1486,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedEnumColumnData[$cacheKey] = $data; + self::$cachedEnumColumnData[$cacheKey] = $data; } @@ -1570,7 +1570,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedDatetimeColumnData[$cacheKey] = $data; + self::$cachedDatetimeColumnData[$cacheKey] = $data; } /** @@ -1646,7 +1646,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedRelationshipColumnData[$cacheKey] = $data; + self::$cachedRelationshipColumnData[$cacheKey] = $data; } public function testUpdateRelationshipColumn(): void @@ -1717,7 +1717,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedIPColumnData[$cacheKey] = $data; + self::$cachedIPColumnData[$cacheKey] = $data; } /** @@ -1794,7 +1794,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedURLColumnData[$cacheKey] = $data; + self::$cachedURLColumnData[$cacheKey] = $data; } /** @@ -1877,7 +1877,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedIndexData[$cacheKey] = [ + self::$cachedIndexData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'index' => $index['body']['data']['tablesDBCreateIndex'], @@ -1952,7 +1952,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedRowData[$cacheKey] = [ + self::$cachedRowData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'row' => $row, diff --git a/tests/e2e/Services/GraphQL/TeamsClientTest.php b/tests/e2e/Services/GraphQL/TeamsClientTest.php index 44cf3c9d12..e6c27f44f8 100644 --- a/tests/e2e/Services/GraphQL/TeamsClientTest.php +++ b/tests/e2e/Services/GraphQL/TeamsClientTest.php @@ -20,8 +20,8 @@ class TeamsClientTest extends Scope protected function setupTeam(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTeam[$key])) { - return static::$cachedTeam[$key]; + if (!empty(self::$cachedTeam[$key])) { + return self::$cachedTeam[$key]; } $projectId = $this->getProject()['$id']; @@ -45,15 +45,15 @@ class TeamsClientTest extends Scope $team = $team['body']['data']['teamsCreate']; $this->assertEquals('Team Name', $team['name']); - static::$cachedTeam[$key] = $team; + self::$cachedTeam[$key] = $team; return $team; } protected function setupMembership(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedMembership[$key])) { - return static::$cachedMembership[$key]; + if (!empty(self::$cachedMembership[$key])) { + return self::$cachedMembership[$key]; } $team = $this->setupTeam(); @@ -82,7 +82,7 @@ class TeamsClientTest extends Scope $this->assertEquals($team['_id'], $membership['teamId']); $this->assertEquals(['developer'], $membership['roles']); - static::$cachedMembership[$key] = $membership; + self::$cachedMembership[$key] = $membership; return $membership; } @@ -211,6 +211,6 @@ class TeamsClientTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedMembership[$key] = []; + self::$cachedMembership[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index bd0939040c..ff6e8e3c6f 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -22,8 +22,8 @@ class TeamsServerTest extends Scope protected function setupTeam(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTeam[$key])) { - return static::$cachedTeam[$key]; + if (!empty(self::$cachedTeam[$key])) { + return self::$cachedTeam[$key]; } $projectId = $this->getProject()['$id']; @@ -47,15 +47,15 @@ class TeamsServerTest extends Scope $team = $team['body']['data']['teamsCreate']; $this->assertEquals('Team Name', $team['name']); - static::$cachedTeam[$key] = $team; + self::$cachedTeam[$key] = $team; return $team; } protected function setupMembership(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedMembership[$key])) { - return static::$cachedMembership[$key]; + if (!empty(self::$cachedMembership[$key])) { + return self::$cachedMembership[$key]; } $team = $this->setupTeam(); @@ -83,15 +83,15 @@ class TeamsServerTest extends Scope $this->assertEquals($team['_id'], $membership['teamId']); $this->assertEquals(['developer'], $membership['roles']); - static::$cachedMembership[$key] = $membership; + self::$cachedMembership[$key] = $membership; return $membership; } protected function setupTeamWithPrefs(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTeamWithPrefs[$key])) { - return static::$cachedTeamWithPrefs[$key]; + if (!empty(self::$cachedTeamWithPrefs[$key])) { + return self::$cachedTeamWithPrefs[$key]; } $team = $this->setupTeam(); @@ -137,7 +137,7 @@ class TeamsServerTest extends Scope $this->assertIsArray($prefs['body']['data']['teamsUpdatePrefs']); $this->assertEquals('{"key":"value"}', $prefs['body']['data']['teamsUpdatePrefs']['data']); - static::$cachedTeamWithPrefs[$key] = $fetchedTeam; + self::$cachedTeamWithPrefs[$key] = $fetchedTeam; return $fetchedTeam; } @@ -349,7 +349,7 @@ class TeamsServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedMembership[$key] = []; + self::$cachedMembership[$key] = []; } #[Group('cl-ignore')] diff --git a/tests/e2e/Services/GraphQL/UsersTest.php b/tests/e2e/Services/GraphQL/UsersTest.php index da9f761567..efe99531be 100644 --- a/tests/e2e/Services/GraphQL/UsersTest.php +++ b/tests/e2e/Services/GraphQL/UsersTest.php @@ -21,8 +21,8 @@ class UsersTest extends Scope protected function setupUser(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedUser[$key])) { - return static::$cachedUser[$key]; + if (!empty(self::$cachedUser[$key])) { + return self::$cachedUser[$key]; } $projectId = $this->getProject()['$id']; @@ -50,15 +50,15 @@ class UsersTest extends Scope $this->assertEquals('Project User', $user['name']); $this->assertEquals($email, $user['email']); - static::$cachedUser[$key] = $user; + self::$cachedUser[$key] = $user; return $user; } protected function setupUserTarget(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedUserTarget[$key])) { - return static::$cachedUserTarget[$key]; + if (!empty(self::$cachedUserTarget[$key])) { + return self::$cachedUserTarget[$key]; } $user = $this->setupUser(); @@ -106,8 +106,8 @@ class UsersTest extends Scope $this->assertEquals(200, $target['headers']['status-code']); $this->assertEquals('random-email@mail.org', $target['body']['data']['usersCreateTarget']['identifier']); - static::$cachedUserTarget[$key] = $target['body']['data']['usersCreateTarget']; - return static::$cachedUserTarget[$key]; + self::$cachedUserTarget[$key] = $target['body']['data']['usersCreateTarget']; + return self::$cachedUserTarget[$key]; } public function testCreateUser(): void @@ -581,7 +581,7 @@ class UsersTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedUserTarget[$key] = []; + self::$cachedUserTarget[$key] = []; } public function testDeleteUser() diff --git a/tests/e2e/Services/Messaging/MessagingBase.php b/tests/e2e/Services/Messaging/MessagingBase.php index 160ea61568..d83b450739 100644 --- a/tests/e2e/Services/Messaging/MessagingBase.php +++ b/tests/e2e/Services/Messaging/MessagingBase.php @@ -2241,7 +2241,7 @@ trait MessagingBase $authKey = $smsDSN->getPassword(); $templateId = $smsDSN->getParam('templateId'); - if (empty($to) || empty($from) || empty($senderId) || empty($authKey)) { + if (empty($to) || empty($senderId) || empty($authKey)) { $this->markTestSkipped('SMS provider not configured'); } diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 1e8b1f5ad3..9e9ce2fbcd 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -64,8 +64,8 @@ trait MigrationsBase */ protected function setupMigrationDatabase(): array { - if (!empty(static::$cachedDatabaseData)) { - return static::$cachedDatabaseData; + if (!empty(self::$cachedDatabaseData)) { + return self::$cachedDatabaseData; } $response = $this->client->call(Client::METHOD_POST, '/databases', [ @@ -81,11 +81,11 @@ trait MigrationsBase $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); - static::$cachedDatabaseData = [ + self::$cachedDatabaseData = [ 'databaseId' => $response['body']['$id'], ]; - return static::$cachedDatabaseData; + return self::$cachedDatabaseData; } /** @@ -94,8 +94,8 @@ trait MigrationsBase */ protected function setupMigrationTable(): array { - if (!empty(static::$cachedTableData)) { - return static::$cachedTableData; + if (!empty(self::$cachedTableData)) { + return self::$cachedTableData; } // Ensure database exists first @@ -141,12 +141,12 @@ trait MigrationsBase $this->assertEquals('available', $response['body']['status']); }, 5000, 500); - static::$cachedTableData = [ + self::$cachedTableData = [ 'databaseId' => $databaseId, 'tableId' => $tableId, ]; - return static::$cachedTableData; + return self::$cachedTableData; } public function performMigrationSync(array $body): array @@ -670,7 +670,7 @@ trait MigrationsBase ]); // Clear the cache since we cleaned up - static::$cachedDatabaseData = []; + self::$cachedDatabaseData = []; } public function testAppwriteMigrationDatabasesRow(): void @@ -757,8 +757,8 @@ trait MigrationsBase ]); // Clear the caches since we cleaned up - static::$cachedDatabaseData = []; - static::$cachedTableData = []; + self::$cachedDatabaseData = []; + self::$cachedTableData = []; } /** @@ -1331,7 +1331,7 @@ trait MigrationsBase ] ); - $this->assertEventually(function () use ($missingColumn, $databaseId, $tableId) { + $this->assertEventually(function () use ($missingColumn) { $migrationId = $missingColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', @@ -1363,7 +1363,7 @@ trait MigrationsBase ] ); - $this->assertEventually(function () use ($missingColumn, $databaseId, $tableId) { + $this->assertEventually(function () use ($missingColumn) { $migrationId = $missingColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', @@ -1395,7 +1395,7 @@ trait MigrationsBase ] ); - $this->assertEventually(function () use ($irrelevantColumn, $databaseId, $tableId) { + $this->assertEventually(function () use ($irrelevantColumn) { $migrationId = $irrelevantColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', @@ -1422,7 +1422,7 @@ trait MigrationsBase ] ); - $this->assertEventually(function () use ($migration, $databaseId, $tableId) { + $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', @@ -1464,7 +1464,7 @@ trait MigrationsBase ] ); - $this->assertEventually(function () use ($migration, $databaseId, $tableId) { + $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', @@ -3825,4 +3825,714 @@ trait MigrationsBase 'x-appwrite-key' => $sourceProject['apiKey'], ]); } + + public function testCreateJSONImport(): void + { + // Make a database + $response = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('Test Database', $response['body']['name']); + + $databaseId = $response['body']['$id']; + + // make a table + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Test table', + 'tableId' => ID::unique(), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($response['body']['name'], 'Test table'); + + $tableId = $response['body']['$id']; + + // make columns + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->assertEquals(202, $response['headers']['status-code']); + $this->assertEquals($response['body']['key'], 'name'); + $this->assertEquals($response['body']['type'], 'string'); + $this->assertEquals($response['body']['size'], 256); + $this->assertEquals($response['body']['required'], true); + + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'min' => 18, + 'max' => 65, + 'required' => true, + ]); + + $this->assertEquals(202, $response['headers']['status-code']); + $this->assertEquals($response['body']['key'], 'age'); + $this->assertEquals($response['body']['type'], 'integer'); + $this->assertEquals($response['body']['min'], 18); + $this->assertEquals($response['body']['max'], 65); + $this->assertEquals($response['body']['required'], true); + + // make a bucket, upload a file to it! + $bucketOne = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket', + 'maximumFileSize' => 2000000, //2MB + 'allowedFileExtensions' => ['json'], + 'compression' => 'gzip', + 'encryption' => true + ]); + $this->assertEquals(201, $bucketOne['headers']['status-code']); + $this->assertNotEmpty($bucketOne['body']['$id']); + + $bucketOneId = $bucketOne['body']['$id']; + + $bucketIds = [ + 'default' => $bucketOneId, + 'missing-column' => $bucketOneId, + 'irrelevant-column' => $bucketOneId, + 'documents-internals' => $bucketOneId, + ]; + + $fileIds = []; + + foreach ($bucketIds as $label => $bucketId) { + $jsonFileName = match ($label) { + 'missing-column', + 'irrelevant-column', + 'documents-internals' => "$label.json", + default => 'documents.json', + }; + + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/'.$jsonFileName), 'application/json', $jsonFileName), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($jsonFileName, $response['body']['name']); + $this->assertEquals('application/json', $response['body']['mimeType']); + + $fileIds[$label] = $response['body']['$id']; + } + + // missing column, fail in worker. + $missingColumn = $this->performJsonMigration( + [ + 'fileId' => $fileIds['missing-column'], + 'bucketId' => $bucketIds['missing-column'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($missingColumn) { + $migrationId = $missingColumn['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('failed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + + /* fails in batch create documents unlike csv which checks headers first! */ + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertGreaterThan(0, $migration['body']['statusCounters'][Resource::TYPE_ROW]['error']); + + $this->assertThat( + implode("\n", $migration['body']['errors']), + $this->stringContains('Missing required attribute') + ); + $this->assertThat( + implode("\n", $migration['body']['errors']), + $this->stringContains('age') + ); + }, 60_000, 500); + + // irrelevant column - email, success. + $irrelevantColumn = $this->performJsonMigration( + [ + 'fileId' => $fileIds['irrelevant-column'], + 'bucketId' => $bucketIds['irrelevant-column'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($irrelevantColumn) { + $migrationId = $irrelevantColumn['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // all data exists, pass. + $migration = $this->performJsonMigration( + [ + 'endpoint' => $this->endpoint, + 'fileId' => $fileIds['default'], + 'bucketId' => $bucketIds['default'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($migration) { + $migrationId = $migration['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // get rows count + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(250)->toString() + ] + ]); + + $this->assertEquals(200, $rows['headers']['status-code']); + $this->assertIsArray($rows['body']['rows']); + $this->assertIsNumeric($rows['body']['total']); + $this->assertEquals(200, $rows['body']['total']); + + // all data exists and includes internals, pass. + $migration = $this->performJsonMigration( + [ + 'endpoint' => $this->endpoint, + 'fileId' => $fileIds['documents-internals'], + 'bucketId' => $bucketIds['documents-internals'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($migration) { + $migrationId = $migration['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertEquals(25, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + } + + private function performJsonMigration(array $body): array + { + return $this->client->call(Client::METHOD_POST, '/migrations/json/imports', [ + 'content-type' => 'application/json', + 'x-appwrite-key' => $this->getProject()['apiKey'], + 'x-appwrite-project' => $this->getProject()['$id'], + ], $body); + } + + /** + * Test JSON export with email notification + */ + public function testCreateJSONExport(): void + { + // Create a database + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Export Database' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create a collection + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Test Export Collection', + 'permissions' => [] + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create a simple attribute like the basic test + $name = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'name', + 'size' => 255, + 'required' => true, + ]); + + $this->assertEquals(202, $name['headers']['status-code']); + + // Create a simple attribute like the basic test + $email = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'email', + 'size' => 255, + 'required' => false, + ]); + + $this->assertEquals(202, $email['headers']['status-code']); + + \sleep(3); + + // Create sample documents + for ($i = 1; $i <= 10; $i++) { + $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Test User ' . $i, + 'email' => 'user' . $i . '@appwrite.io' + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); + } + + // Verify documents were created + $docs = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $docs['headers']['status-code']); + $this->assertEquals(10, $docs['body']['total'], 'Expected 10 documents but got ' . $docs['body']['total']); + + // Perform JSON export with notification enabled (uses internal bucket) + $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => 'test-json-export', + 'columns' => [], + 'queries' => [], + 'notify' => true + ]); + + $this->assertEquals(202, $migration['headers']['status-code']); + $this->assertNotEmpty($migration['body']['$id']); + $migrationId = $migration['body']['$id']; + + $this->assertEventually(function () use ($migrationId) { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('finished', $response['body']['stage']); + $this->assertEquals('completed', $response['body']['status']); + $this->assertEquals('Appwrite', $response['body']['source']); + $this->assertEquals('JSON', $response['body']['destination']); + + return true; + }, 30_000, 500); + + // Check that email was sent with download link + $lastEmail = $this->getLastEmail(); + $this->assertNotEmpty($lastEmail); + $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); + + // Extract download URL from email HTML + \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $lastEmail['html'], $matches); + $this->assertNotEmpty($matches[1], 'Download URL not found in email'); + $downloadUrl = html_entity_decode($matches[1]); + + // Parse the URL to extract components + $components = \parse_url($downloadUrl); + $this->assertNotEmpty($components); + \parse_str($components['query'] ?? '', $queryParams); + $this->assertArrayHasKey('jwt', $queryParams, 'JWT not found in download URL'); + $this->assertNotEmpty($queryParams['jwt']); + $this->assertArrayHasKey('project', $queryParams, 'Project not found in download URL'); + $this->assertStringContainsString('/storage/buckets/default/files/', $downloadUrl); + + // Test download with JWT + $path = \str_replace('/v1', '', $components['path']); + $downloadWithJwt = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); + $this->assertEquals(200, $downloadWithJwt['headers']['status-code'], 'Failed to download file with JWT'); + + // Verify the downloaded content is valid JSON + $jsonData = $downloadWithJwt['body']; + $this->assertNotEmpty($jsonData, 'JSON export should not be empty'); + $decoded = json_decode($jsonData, true); + $this->assertIsArray($decoded, 'JSON should be valid and decodable'); + $this->assertCount(10, $decoded, 'JSON should contain 10 documents'); + $this->assertArrayHasKey('name', $decoded[0], 'JSON documents should contain name field'); + $this->assertArrayHasKey('email', $decoded[0], 'JSON documents should contain email field'); + $this->assertStringContainsString('Test User', $decoded[0]['name'], 'JSON should contain test data'); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + } + + public function testCreateVectorsDBJSONExport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create vectorsdb database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'VectorsDB Export Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection with dimension 16 + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'VecExportCol', + 'dimension' => 16, + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Seed 5 documents + for ($i = 1; $i <= 5; $i++) { + $embeddings = array_map(fn () => round((mt_rand() / mt_getrandmax()) * 2 - 1, 6), range(1, 16)); + $doc = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $embeddings, + 'metadata' => ['title' => 'Doc ' . $i, 'score' => round($i * 0.2, 1)] + ] + ]); + $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create vector document ' . $i); + } + + // Trigger JSON export + $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', $headers, [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => 'vectorsdb-export-test', + 'columns' => [], + 'queries' => [], + 'notify' => false, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + $migrationId = $migration['body']['$id']; + + // Poll until completed + $this->assertEventually(function () use ($migrationId, $headers) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('Appwrite', $migration['body']['source']); + $this->assertEquals('JSON', $migration['body']['destination']); + }, 30_000, 500); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, $headers); + } + + public function testCreateVectorsDBJSONImport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create vectorsdb database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'VectorsDB Import Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection with dimension 16 + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'VecImportCol', + 'dimension' => 16, + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create bucket and upload test file + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'VectorsDB Import Bucket', + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['json'], + ]); + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/json/vectorsdb-documents.json'), 'application/json', 'vectorsdb-documents.json'), + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + + // Trigger import + $migration = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $collectionId, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + + // Poll until completed + $this->assertEventually(function () use ($migration, $headers) { + $migrationId = $migration['body']['$id']; + $result = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $result['headers']['status-code']); + $this->assertEquals('finished', $result['body']['stage']); + $this->assertEquals('completed', $result['body']['status']); + $this->assertEquals('JSON', $result['body']['source']); + $this->assertEquals('Appwrite', $result['body']['destination']); + }, 30_000, 500); + + // Verify documents were imported + $docs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers); + $this->assertEquals(200, $docs['headers']['status-code']); + $this->assertEquals(10, $docs['body']['total'], 'Should have imported 10 vectorsdb documents'); + + // Verify first document structure + $firstDoc = $docs['body']['documents'][0]; + $this->assertArrayHasKey('embeddings', $firstDoc); + $this->assertCount(16, $firstDoc['embeddings'], 'Imported embeddings should have 16 dimensions'); + $this->assertArrayHasKey('metadata', $firstDoc); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, $headers); + } + + public function testCreateDocumentsDBJSONExport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create documentsdb database + $database = $this->client->call(Client::METHOD_POST, '/documentsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Export Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection (schemaless — no attributes needed) + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'DocExportCol', + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Seed 5 documents + for ($i = 1; $i <= 5; $i++) { + $doc = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'User ' . $i, + 'email' => 'user' . $i . '@test.com', + 'age' => 20 + $i, + 'address' => ['city' => 'City ' . $i, 'zip' => '1000' . $i] + ] + ]); + $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); + } + + // Trigger JSON export + $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', $headers, [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => 'documentsdb-export-test', + 'columns' => [], + 'queries' => [], + 'notify' => false, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + $migrationId = $migration['body']['$id']; + + // Poll until completed + $this->assertEventually(function () use ($migrationId, $headers) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('Appwrite', $migration['body']['source']); + $this->assertEquals('JSON', $migration['body']['destination']); + }, 30_000, 500); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, $headers); + } + + public function testCreateDocumentsDBJSONImport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create documentsdb database + $database = $this->client->call(Client::METHOD_POST, '/documentsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Import Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection (schemaless) + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'DocImportCol', + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create bucket and upload test file + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'DocumentsDB Import Bucket', + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['json'], + ]); + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/json/documentsdb-documents.json'), 'application/json', 'documentsdb-documents.json'), + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + + // Trigger import + $migration = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $collectionId, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + + // Poll until completed + $this->assertEventually(function () use ($migration, $headers) { + $migrationId = $migration['body']['$id']; + $result = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $result['headers']['status-code']); + $this->assertEquals('finished', $result['body']['stage']); + $this->assertEquals('completed', $result['body']['status']); + $this->assertEquals('JSON', $result['body']['source']); + $this->assertEquals('Appwrite', $result['body']['destination']); + }, 30_000, 500); + + // Verify documents were imported + $docs = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers); + $this->assertEquals(200, $docs['headers']['status-code']); + $this->assertEquals(10, $docs['body']['total'], 'Should have imported 10 documentsdb documents'); + + // Verify first document has nested data + $firstDoc = $docs['body']['documents'][0]; + $this->assertArrayHasKey('name', $firstDoc); + $this->assertArrayHasKey('address', $firstDoc); + $this->assertIsArray($firstDoc['address']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, $headers); + } + } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php new file mode 100644 index 0000000000..fda5ef377f --- /dev/null +++ b/tests/e2e/Services/Project/KeysBase.php @@ -0,0 +1,806 @@ +createKey( + ID::unique(), + 'My API Key', + ['users.read', 'users.write'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('My API Key', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertSame('', $key['body']['expire']); + $this->assertSame('', $key['body']['accessedAt']); + $this->assertSame([], $key['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($key['body']['$id'], $get['body']['$id']); + $this->assertSame('My API Key', $get['body']['name']); + $this->assertSame(['users.read', 'users.write'], $get['body']['scopes']); + + // Verify via LIST + $list = $this->listKeys(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithExpire(): void + { + $expire = '2030-01-01T00:00:00.000+00:00'; + + $key = $this->createKey( + ID::unique(), + 'Expiring Key', + ['users.read'], + $expire, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($expire, $key['body']['expire']); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithNullScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Null Scopes Key', + null, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithoutAuthentication(): void + { + $response = $this->createKey( + ID::unique(), + 'No Auth Key', + ['users.read'], + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidId(): void + { + $key = $this->createKey( + '!invalid-id!', + 'Invalid ID Key', + ['users.read'], + ); + + $this->assertSame(400, $key['headers']['status-code']); + } + + public function testCreateKeyMissingName(): void + { + $response = $this->createKey( + ID::unique(), + null, + ['users.read'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidScope(): void + { + $response = $this->createKey( + ID::unique(), + 'Invalid Scope Key', + ['invalid.scope'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyDuplicateId(): void + { + $keyId = ID::unique(); + + $key = $this->createKey( + $keyId, + 'Key Dup 1', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createKey( + $keyId, + 'Key Dup 2', + ['users.write'], + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('key_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testCreateKeyCustomId(): void + { + $customId = 'my-custom-key-id'; + + $key = $this->createKey( + $customId, + 'Custom ID Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($customId, $key['body']['$id']); + + // Verify via GET + $get = $this->getKey($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deleteKey($customId); + } + + // ========================================================================= + // Update key tests + // ========================================================================= + + public function testUpdateKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Original Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Update name, scopes, and expire + $expire = '2031-06-15T12:00:00.000+00:00'; + $updated = $this->updateKey($keyId, 'Updated Key', ['users.write', 'databases.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($keyId, $updated['body']['$id']); + $this->assertSame('Updated Key', $updated['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $updated['body']['scopes']); + $this->assertSame($expire, $updated['body']['expire']); + + // Verify update persisted via GET + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Key', $get['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $get['body']['scopes']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyName(): void + { + $key = $this->createKey( + ID::unique(), + 'Name Before', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Name After', ['users.read']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('Name After', $updated['body']['name']); + $this->assertSame(['users.read'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Scopes Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Scopes Key', ['databases.read', 'databases.write']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(['databases.read', 'databases.write'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeySetExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'No Expire Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame('', $key['body']['expire']); + $keyId = $key['body']['$id']; + + $expire = '2032-12-31T23:59:59.000+00:00'; + $updated = $this->updateKey($keyId, 'No Expire Key', ['users.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($expire, $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyRemoveExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'Expire Key', + ['users.read'], + '2030-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Remove expire by setting to null + $updated = $this->updateKey($keyId, 'Expire Key', ['users.read'], null); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('', $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Update Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt update without authentication + $response = $this->updateKey($keyId, 'Updated Name', ['users.read'], null, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyNotFound(): void + { + $updated = $this->updateKey('non-existent-id', 'New Name', ['users.read']); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('key_not_found', $updated['body']['type']); + } + + public function testUpdateKeyInvalidScope(): void + { + $key = $this->createKey( + ID::unique(), + 'Invalid Scope Update', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Invalid Scope Update', ['invalid.scope']); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // Get key tests + // ========================================================================= + + public function testGetKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Get Test Key', + ['users.read', 'databases.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $get = $this->getKey($keyId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($keyId, $get['body']['$id']); + $this->assertSame('Get Test Key', $get['body']['name']); + $this->assertSame(['users.read', 'databases.read'], $get['body']['scopes']); + $this->assertNotEmpty($get['body']['secret']); + $this->assertSame('', $get['body']['expire']); + $this->assertSame('', $get['body']['accessedAt']); + $this->assertSame([], $get['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testGetKeyNotFound(): void + { + $get = $this->getKey('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testGetKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Get Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt GET without authentication + $response = $this->getKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // List keys tests + // ========================================================================= + + public function testListKeys(): void + { + // Create multiple keys + $key1 = $this->createKey( + ID::unique(), + 'List Key Alpha', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'List Key Beta', + ['databases.read'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + $key3 = $this->createKey( + ID::unique(), + 'List Key Gamma', + ['users.write'], + ); + $this->assertSame(201, $key3['headers']['status-code']); + + // List all + $list = $this->listKeys(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['keys'])); + $this->assertIsArray($list['body']['keys']); + + // Verify structure of returned keys + foreach ($list['body']['keys'] as $key) { + $this->assertArrayHasKey('$id', $key); + $this->assertArrayHasKey('$createdAt', $key); + $this->assertArrayHasKey('$updatedAt', $key); + $this->assertArrayHasKey('name', $key); + $this->assertArrayHasKey('scopes', $key); + $this->assertArrayHasKey('secret', $key); + $this->assertArrayHasKey('expire', $key); + $this->assertArrayHasKey('accessedAt', $key); + $this->assertArrayHasKey('sdks', $key); + } + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + $this->deleteKey($key3['body']['$id']); + } + + public function testListKeysWithLimit(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Limit Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Limit Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // List with limit 1 + $list = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['keys']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutTotal(): void + { + $key = $this->createKey( + ID::unique(), + 'No Total Key', + ['users.read'], + ); + $this->assertSame(201, $key['headers']['status-code']); + + // List with total=false + $list = $this->listKeys(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testListKeysCursorPagination(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Cursor Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Cursor Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['keys']); + $cursorId = $page1['body']['keys'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listKeys([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['keys']); + $this->assertNotEquals($cursorId, $page2['body']['keys'][0]['$id']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutAuthentication(): void + { + $response = $this->listKeys(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListKeysInvalidCursor(): void + { + $list = $this->listKeys([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + // ========================================================================= + // Delete key tests + // ========================================================================= + + public function testDeleteKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Verify it exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getKey($keyId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testDeleteKeyNotFound(): void + { + $delete = $this->deleteKey('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + public function testDeleteKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Auth Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->deleteKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testDeleteKeyRemovedFromList(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete List Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Get list count before delete + $listBefore = $this->listKeys(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listKeys(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted key is not in the list + $ids = \array_column($listAfter['body']['keys'], '$id'); + $this->assertNotContains($keyId, $ids); + } + + public function testDeleteKeyDoubleDelete(): void + { + $key = $this->createKey( + ID::unique(), + 'Double Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // First delete succeeds + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteKey($keyId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array|null $scopes + */ + protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = [ + 'keyId' => $keyId, + 'scopes' => $scopes, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/keys', $headers, $params); + } + + /** + * @param array|null $scopes + */ + protected function updateKey(string $keyId, ?string $name = null, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($scopes !== null) { + $params['scopes'] = $scopes; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, $params); + } + + protected function getKey(string $keyId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/keys/' . $keyId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listKeys(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/keys', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deleteKey(string $keyId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers); + } +} diff --git a/tests/e2e/Services/Project/KeysConsoleClientTest.php b/tests/e2e/Services/Project/KeysConsoleClientTest.php new file mode 100644 index 0000000000..ad6ed28b77 --- /dev/null +++ b/tests/e2e/Services/Project/KeysConsoleClientTest.php @@ -0,0 +1,14 @@ +updateLabels(['frontend', 'backend']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['labels']); + $this->assertCount(2, $response['body']['labels']); + $this->assertContains('frontend', $response['body']['labels']); + $this->assertContains('backend', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsReplace(): void + { + $response = $this->updateLabels(['alpha', 'beta']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['labels']); + $this->assertContains('alpha', $response['body']['labels']); + $this->assertContains('beta', $response['body']['labels']); + + // Replace with new labels + $response = $this->updateLabels(['gamma', 'delta', 'epsilon']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(3, $response['body']['labels']); + $this->assertContains('gamma', $response['body']['labels']); + $this->assertContains('delta', $response['body']['labels']); + $this->assertContains('epsilon', $response['body']['labels']); + $this->assertNotContains('alpha', $response['body']['labels']); + $this->assertNotContains('beta', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsEmpty(): void + { + // Set some labels first + $response = $this->updateLabels(['toRemove']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['labels']); + + // Clear all labels + $response = $this->updateLabels([]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['labels']); + $this->assertCount(0, $response['body']['labels']); + } + + public function testUpdateLabelsDeduplicated(): void + { + $response = $this->updateLabels(['duplicate', 'duplicate', 'unique']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['labels']); + $this->assertContains('duplicate', $response['body']['labels']); + $this->assertContains('unique', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsSingleLabel(): void + { + $response = $this->updateLabels(['solo']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['labels']); + $this->assertContains('solo', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsWithoutAuthentication(): void + { + $response = $this->updateLabels(['unauthorized'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidLabelTooLong(): void + { + $response = $this->updateLabels([str_repeat('a', 37)]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidLabelCharacters(): void + { + $response = $this->updateLabels(['invalid-label!']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsAlphanumericOnly(): void + { + $response = $this->updateLabels(['ABC123', 'lowercase', 'UPPERCASE', '0123456789']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(4, $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsMaxLength(): void + { + $label = str_repeat('a', 36); + $response = $this->updateLabels([$label]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['labels']); + $this->assertContains($label, $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsIdempotent(): void + { + $labels = ['stable', 'production']; + + $first = $this->updateLabels($labels); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateLabels($labels); + $this->assertSame(200, $second['headers']['status-code']); + + $this->assertSame($first['body']['labels'], $second['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsDeduplicatedOrder(): void + { + $response = $this->updateLabels(['b', 'a', 'b']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['labels']); + $this->assertSame('b', $response['body']['labels'][0]); + $this->assertSame('a', $response['body']['labels'][1]); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsInvalidHyphen(): void + { + $response = $this->updateLabels(['my-label']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidUnderscore(): void + { + $response = $this->updateLabels(['my_label']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidSpace(): void + { + $response = $this->updateLabels(['my label']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidEmptyString(): void + { + $response = $this->updateLabels(['']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsResponseModel(): void + { + $response = $this->updateLabels(['test']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('labels', $response['body']); + $this->assertIsArray($response['body']['labels']); + $this->assertContains('test', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + // Helpers + + /** + * @param array $labels + */ + protected function updateLabels(array $labels, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(\Tests\E2E\Client::METHOD_PUT, '/project/labels', $headers, [ + 'labels' => $labels, + ]); + } +} diff --git a/tests/e2e/Services/Project/LabelsConsoleClientTest.php b/tests/e2e/Services/Project/LabelsConsoleClientTest.php new file mode 100644 index 0000000000..dd724338d6 --- /dev/null +++ b/tests/e2e/Services/Project/LabelsConsoleClientTest.php @@ -0,0 +1,14 @@ +createWebPlatform( + ID::unique(), + 'My Web App', + 'app.example.com', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Web App', $platform['body']['name']); + $this->assertSame('web', $platform['body']['type']); + $this->assertSame('app.example.com', $platform['body']['hostname']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platform['body']['$id'], $get['body']['$id']); + $this->assertSame('My Web App', $get['body']['name']); + $this->assertSame('web', $get['body']['type']); + $this->assertSame('app.example.com', $get['body']['hostname']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['platforms'])); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateWebPlatformWithoutAuthentication(): void + { + $response = $this->createWebPlatform( + ID::unique(), + 'No Auth Web', + 'noauth.example.com', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateWebPlatformInvalidId(): void + { + $platform = $this->createWebPlatform( + '!invalid-id!', + 'Invalid ID Web', + 'invalid.example.com', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateWebPlatformMissingName(): void + { + $response = $this->createWebPlatform( + ID::unique(), + null, + 'missing.example.com', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWebPlatformEmptyHostname(): void + { + $response = $this->createWebPlatform( + ID::unique(), + 'Empty Hostname', + '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWebPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createWebPlatform( + $platformId, + 'Web Dup 1', + 'dup1.example.com', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createWebPlatform( + $platformId, + 'Web Dup 2', + 'dup2.example.com', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateWebPlatformCustomId(): void + { + $customId = 'my-custom-web-platform'; + + $platform = $this->createWebPlatform( + $customId, + 'Custom ID Web', + 'custom.example.com', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Apple platform tests + // ========================================================================= + + public function testCreateApplePlatform(): void + { + $platform = $this->createApplePlatform( + ID::unique(), + 'My Apple App', + 'com.example.myapp', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Apple App', $platform['body']['name']); + $this->assertSame('apple', $platform['body']['type']); + $this->assertSame('com.example.myapp', $platform['body']['bundleIdentifier']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platform['body']['$id'], $get['body']['$id']); + $this->assertSame('My Apple App', $get['body']['name']); + $this->assertSame('apple', $get['body']['type']); + $this->assertSame('com.example.myapp', $get['body']['bundleIdentifier']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['platforms'])); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateApplePlatformWithoutAuthentication(): void + { + $response = $this->createApplePlatform( + ID::unique(), + 'No Auth Apple', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateApplePlatformInvalidId(): void + { + $platform = $this->createApplePlatform( + '!invalid-id!', + 'Invalid ID Apple', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateApplePlatformMissingName(): void + { + $response = $this->createApplePlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateApplePlatformMissingIdentifier(): void + { + $response = $this->createApplePlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateApplePlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createApplePlatform( + $platformId, + 'Apple Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createApplePlatform( + $platformId, + 'Apple Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateApplePlatformCustomId(): void + { + $customId = 'my-custom-apple-platform'; + + $platform = $this->createApplePlatform( + $customId, + 'Custom ID Apple', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Android platform tests + // ========================================================================= + + public function testCreateAndroidPlatform(): void + { + $platform = $this->createAndroidPlatform( + ID::unique(), + 'My Android App', + 'com.example.android', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Android App', $platform['body']['name']); + $this->assertSame('android', $platform['body']['type']); + $this->assertSame('com.example.android', $platform['body']['applicationId']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('android', $get['body']['type']); + $this->assertSame('com.example.android', $get['body']['applicationId']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateAndroidPlatformWithoutAuthentication(): void + { + $response = $this->createAndroidPlatform( + ID::unique(), + 'No Auth Android', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateAndroidPlatformInvalidId(): void + { + $platform = $this->createAndroidPlatform( + '!invalid-id!', + 'Invalid ID Android', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateAndroidPlatformMissingName(): void + { + $response = $this->createAndroidPlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateAndroidPlatformMissingIdentifier(): void + { + $response = $this->createAndroidPlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateAndroidPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createAndroidPlatform( + $platformId, + 'Android Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createAndroidPlatform( + $platformId, + 'Android Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateAndroidPlatformCustomId(): void + { + $customId = 'my-custom-android-platform'; + + $platform = $this->createAndroidPlatform( + $customId, + 'Custom ID Android', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Windows platform tests + // ========================================================================= + + public function testCreateWindowsPlatform(): void + { + $platform = $this->createWindowsPlatform( + ID::unique(), + 'My Windows App', + 'com.example.windows', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Windows App', $platform['body']['name']); + $this->assertSame('windows', $platform['body']['type']); + $this->assertSame('com.example.windows', $platform['body']['packageIdentifierName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('windows', $get['body']['type']); + $this->assertSame('com.example.windows', $get['body']['packageIdentifierName']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateWindowsPlatformWithoutAuthentication(): void + { + $response = $this->createWindowsPlatform( + ID::unique(), + 'No Auth Windows', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateWindowsPlatformInvalidId(): void + { + $platform = $this->createWindowsPlatform( + '!invalid-id!', + 'Invalid ID Windows', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateWindowsPlatformMissingName(): void + { + $response = $this->createWindowsPlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWindowsPlatformMissingIdentifier(): void + { + $response = $this->createWindowsPlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWindowsPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createWindowsPlatform( + $platformId, + 'Windows Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createWindowsPlatform( + $platformId, + 'Windows Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateWindowsPlatformCustomId(): void + { + $customId = 'my-custom-windows-platform'; + + $platform = $this->createWindowsPlatform( + $customId, + 'Custom ID Windows', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Linux platform tests + // ========================================================================= + + public function testCreateLinuxPlatform(): void + { + $platform = $this->createLinuxPlatform( + ID::unique(), + 'My Linux App', + 'com.example.linux', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Linux App', $platform['body']['name']); + $this->assertSame('linux', $platform['body']['type']); + $this->assertSame('com.example.linux', $platform['body']['packageName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('linux', $get['body']['type']); + $this->assertSame('com.example.linux', $get['body']['packageName']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateLinuxPlatformWithoutAuthentication(): void + { + $response = $this->createLinuxPlatform( + ID::unique(), + 'No Auth Linux', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateLinuxPlatformInvalidId(): void + { + $platform = $this->createLinuxPlatform( + '!invalid-id!', + 'Invalid ID Linux', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateLinuxPlatformMissingName(): void + { + $response = $this->createLinuxPlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateLinuxPlatformMissingIdentifier(): void + { + $response = $this->createLinuxPlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateLinuxPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createLinuxPlatform( + $platformId, + 'Linux Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createLinuxPlatform( + $platformId, + 'Linux Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateLinuxPlatformCustomId(): void + { + $customId = 'my-custom-linux-platform'; + + $platform = $this->createLinuxPlatform( + $customId, + 'Custom ID Linux', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Update Web platform tests + // ========================================================================= + + public function testUpdateWebPlatform(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Original Web', 'original.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWebPlatform($platformId, 'Updated Web', 'updated.example.com'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Web', $updated['body']['name']); + $this->assertSame('updated.example.com', $updated['body']['hostname']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Web', $get['body']['name']); + $this->assertSame('updated.example.com', $get['body']['hostname']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWebPlatformWithoutAuthentication(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Auth Update Web', 'authupdate.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateWebPlatform($platformId, 'Updated', 'updated.example.com', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWebPlatformNotFound(): void + { + $updated = $this->updateWebPlatform('non-existent-id', 'New Name', 'new.example.com'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateWebPlatformMethodUnsupported(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Android Platform', 'com.example.app'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWebPlatform($platformId, 'Updated Name', 'updated.example.com'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Apple platform tests + // ========================================================================= + + public function testUpdateApplePlatform(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Original Apple', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateApplePlatform($platformId, 'Updated Apple', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Apple', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['bundleIdentifier']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Apple', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['bundleIdentifier']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateApplePlatformWithoutAuthentication(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Auth Update Apple', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateApplePlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateApplePlatformNotFound(): void + { + $updated = $this->updateApplePlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateApplePlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateApplePlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateApplePlatformMissingIdentifier(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Missing Id Apple', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateApplePlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Android platform tests + // ========================================================================= + + public function testUpdateAndroidPlatform(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Original Android', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateAndroidPlatform($platformId, 'Updated Android', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Android', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['applicationId']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Android', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['applicationId']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateAndroidPlatformWithoutAuthentication(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Auth Update Android', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateAndroidPlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateAndroidPlatformNotFound(): void + { + $updated = $this->updateAndroidPlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateAndroidPlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateAndroidPlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateAndroidPlatformMissingIdentifier(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Missing Id Android', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateAndroidPlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Windows platform tests + // ========================================================================= + + public function testUpdateWindowsPlatform(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Original Windows', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWindowsPlatform($platformId, 'Updated Windows', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Windows', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['packageIdentifierName']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Windows', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['packageIdentifierName']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWindowsPlatformWithoutAuthentication(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Auth Update Windows', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateWindowsPlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWindowsPlatformNotFound(): void + { + $updated = $this->updateWindowsPlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateWindowsPlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWindowsPlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWindowsPlatformMissingIdentifier(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Missing Id Windows', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWindowsPlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Linux platform tests + // ========================================================================= + + public function testUpdateLinuxPlatform(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Original Linux', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateLinuxPlatform($platformId, 'Updated Linux', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Linux', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['packageName']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Linux', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['packageName']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateLinuxPlatformWithoutAuthentication(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Auth Update Linux', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateLinuxPlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateLinuxPlatformNotFound(): void + { + $updated = $this->updateLinuxPlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateLinuxPlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateLinuxPlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateLinuxPlatformMissingIdentifier(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Missing Id Linux', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateLinuxPlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Get platform tests + // ========================================================================= + + public function testGetWebPlatform(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Get Test Web', 'gettest.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Web', $get['body']['name']); + $this->assertSame('web', $get['body']['type']); + $this->assertSame('gettest.example.com', $get['body']['hostname']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetApplePlatform(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Get Test Apple', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Apple', $get['body']['name']); + $this->assertSame('apple', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['bundleIdentifier']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetAndroidPlatform(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Get Test Android', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Android', $get['body']['name']); + $this->assertSame('android', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['applicationId']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetWindowsPlatform(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Get Test Windows', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Windows', $get['body']['name']); + $this->assertSame('windows', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['packageIdentifierName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetLinuxPlatform(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Get Test Linux', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Linux', $get['body']['name']); + $this->assertSame('linux', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['packageName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetPlatformNotFound(): void + { + $get = $this->getPlatform('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('platform_not_found', $get['body']['type']); + } + + public function testGetPlatformWithoutAuthentication(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Auth Get Web', 'authget.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->getPlatform($platformId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // List platforms tests + // ========================================================================= + + public function testListPlatforms(): void + { + // Create one of each platform type + $web = $this->createWebPlatform(ID::unique(), 'List Web', 'listweb.example.com'); + $this->assertSame(201, $web['headers']['status-code']); + + $apple = $this->createApplePlatform(ID::unique(), 'List Apple', 'com.example.listapple'); + $this->assertSame(201, $apple['headers']['status-code']); + + $android = $this->createAndroidPlatform(ID::unique(), 'List Android', 'com.example.listandroid'); + $this->assertSame(201, $android['headers']['status-code']); + + $windows = $this->createWindowsPlatform(ID::unique(), 'List Windows', 'com.example.listwindows'); + $this->assertSame(201, $windows['headers']['status-code']); + + $linux = $this->createLinuxPlatform(ID::unique(), 'List Linux', 'com.example.listlinux'); + $this->assertSame(201, $linux['headers']['status-code']); + + // List all + $list = $this->listPlatforms(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(5, $list['body']['total']); + $this->assertGreaterThanOrEqual(5, \count($list['body']['platforms'])); + $this->assertIsArray($list['body']['platforms']); + + // Verify structure of returned platforms + foreach ($list['body']['platforms'] as $platform) { + $this->assertArrayHasKey('$id', $platform); + $this->assertArrayHasKey('$createdAt', $platform); + $this->assertArrayHasKey('$updatedAt', $platform); + $this->assertArrayHasKey('name', $platform); + $this->assertArrayHasKey('type', $platform); + } + + // Cleanup + $this->deletePlatform($web['body']['$id']); + $this->deletePlatform($apple['body']['$id']); + $this->deletePlatform($android['body']['$id']); + $this->deletePlatform($windows['body']['$id']); + $this->deletePlatform($linux['body']['$id']); + } + + public function testListPlatformsWithLimit(): void + { + $platform1 = $this->createWebPlatform(ID::unique(), 'Limit Web 1', 'limit1.example.com'); + $this->assertSame(201, $platform1['headers']['status-code']); + + $platform2 = $this->createAndroidPlatform(ID::unique(), 'Limit Android 2', 'com.example.limit2'); + $this->assertSame(201, $platform2['headers']['status-code']); + + $list = $this->listPlatforms([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['platforms']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform1['body']['$id']); + $this->deletePlatform($platform2['body']['$id']); + } + + public function testListPlatformsWithOffset(): void + { + $platform1 = $this->createWebPlatform(ID::unique(), 'Offset Web 1', 'offset1.example.com'); + $this->assertSame(201, $platform1['headers']['status-code']); + + $platform2 = $this->createAndroidPlatform(ID::unique(), 'Offset Android 2', 'com.example.offset2'); + $this->assertSame(201, $platform2['headers']['status-code']); + + $listAll = $this->listPlatforms(null, true); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['platforms']); + + $listOffset = $this->listPlatforms([ + Query::offset(1)->toString(), + ], true); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['platforms']); + + // Cleanup + $this->deletePlatform($platform1['body']['$id']); + $this->deletePlatform($platform2['body']['$id']); + } + + public function testListPlatformsWithoutTotal(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'No Total Web', 'nototal.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + + $list = $this->listPlatforms(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['platforms'])); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testListPlatformsCursorPagination(): void + { + $platform1 = $this->createWebPlatform(ID::unique(), 'Cursor Web 1', 'cursor1.example.com'); + $this->assertSame(201, $platform1['headers']['status-code']); + + $platform2 = $this->createAndroidPlatform(ID::unique(), 'Cursor Android 2', 'com.example.cursor2'); + $this->assertSame(201, $platform2['headers']['status-code']); + + $page1 = $this->listPlatforms([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['platforms']); + $cursorId = $page1['body']['platforms'][0]['$id']; + + $page2 = $this->listPlatforms([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['platforms']); + $this->assertNotEquals($cursorId, $page2['body']['platforms'][0]['$id']); + + // Cleanup + $this->deletePlatform($platform1['body']['$id']); + $this->deletePlatform($platform2['body']['$id']); + } + + public function testListPlatformsWithoutAuthentication(): void + { + $response = $this->listPlatforms(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListPlatformsInvalidCursor(): void + { + $list = $this->listPlatforms([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + public function testListPlatformsFilterByType(): void + { + $web = $this->createWebPlatform(ID::unique(), 'Filter Web', 'filter.example.com'); + $this->assertSame(201, $web['headers']['status-code']); + + $android = $this->createAndroidPlatform(ID::unique(), 'Filter Android', 'com.example.filter'); + $this->assertSame(201, $android['headers']['status-code']); + + // Filter by web type + $list = $this->listPlatforms([ + Query::equal('type', ['web'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + foreach ($list['body']['platforms'] as $platform) { + $this->assertSame('web', $platform['type']); + } + + // Filter by android type + $list = $this->listPlatforms([ + Query::equal('type', ['android'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + foreach ($list['body']['platforms'] as $platform) { + $this->assertSame('android', $platform['type']); + } + + // Cleanup + $this->deletePlatform($web['body']['$id']); + $this->deletePlatform($android['body']['$id']); + } + + public function testListPlatformsFilterByName(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'UniqueFilterName', 'filtername.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + + $list = $this->listPlatforms([ + Query::equal('name', ['UniqueFilterName'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertSame('UniqueFilterName', $list['body']['platforms'][0]['name']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testListPlatformsFilterByHostname(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Hostname Filter', 'uniquehostname.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + + $list = $this->listPlatforms([ + Query::equal('hostname', ['uniquehostname.example.com'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertSame('uniquehostname.example.com', $list['body']['platforms'][0]['hostname']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + // ========================================================================= + // Delete platform tests + // ========================================================================= + + public function testDeletePlatform(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Delete Web', 'delete.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + // Verify it exists + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deletePlatform($platformId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getPlatform($platformId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('platform_not_found', $get['body']['type']); + } + + public function testDeletePlatformNotFound(): void + { + $delete = $this->deletePlatform('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('platform_not_found', $delete['body']['type']); + } + + public function testDeletePlatformWithoutAuthentication(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Delete Auth Web', 'deleteauth.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->deletePlatform($platformId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testDeletePlatformRemovedFromList(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Delete List Web', 'deletelist.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $listBefore = $this->listPlatforms(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + $delete = $this->deletePlatform($platformId); + $this->assertSame(204, $delete['headers']['status-code']); + + $listAfter = $this->listPlatforms(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + $ids = \array_column($listAfter['body']['platforms'], '$id'); + $this->assertNotContains($platformId, $ids); + } + + public function testDeletePlatformDoubleDelete(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Double Delete Web', 'doubledelete.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $delete = $this->deletePlatform($platformId); + $this->assertSame(204, $delete['headers']['status-code']); + + $delete = $this->deletePlatform($platformId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('platform_not_found', $delete['body']['type']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function createWebPlatform(string $platformId, ?string $name, ?string $hostname, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($hostname !== null) { + $params['hostname'] = $hostname; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/platforms/web', $headers, $params); + } + + protected function createApplePlatform(string $platformId, ?string $name, ?string $bundleIdentifier, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($bundleIdentifier !== null) { + $params['bundleIdentifier'] = $bundleIdentifier; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/platforms/apple', $headers, $params); + } + + protected function createAndroidPlatform(string $platformId, ?string $name, ?string $applicationId, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($applicationId !== null) { + $params['applicationId'] = $applicationId; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/platforms/android', $headers, $params); + } + + protected function createWindowsPlatform(string $platformId, ?string $name, ?string $packageIdentifierName, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageIdentifierName !== null) { + $params['packageIdentifierName'] = $packageIdentifierName; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/platforms/windows', $headers, $params); + } + + protected function createLinuxPlatform(string $platformId, ?string $name, ?string $packageName, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageName !== null) { + $params['packageName'] = $packageName; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/platforms/linux', $headers, $params); + } + + protected function updateWebPlatform(string $platformId, ?string $name = null, ?string $hostname = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($hostname !== null) { + $params['hostname'] = $hostname; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/platforms/web/' . $platformId, $headers, $params); + } + + protected function updateApplePlatform(string $platformId, ?string $name = null, ?string $bundleIdentifier = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($bundleIdentifier !== null) { + $params['bundleIdentifier'] = $bundleIdentifier; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/platforms/apple/' . $platformId, $headers, $params); + } + + protected function updateAndroidPlatform(string $platformId, ?string $name = null, ?string $applicationId = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($applicationId !== null) { + $params['applicationId'] = $applicationId; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/platforms/android/' . $platformId, $headers, $params); + } + + protected function updateWindowsPlatform(string $platformId, ?string $name = null, ?string $packageIdentifierName = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageIdentifierName !== null) { + $params['packageIdentifierName'] = $packageIdentifierName; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/platforms/windows/' . $platformId, $headers, $params); + } + + protected function updateLinuxPlatform(string $platformId, ?string $name = null, ?string $packageName = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageName !== null) { + $params['packageName'] = $packageName; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/platforms/linux/' . $platformId, $headers, $params); + } + + protected function getPlatform(string $platformId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/platforms/' . $platformId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listPlatforms(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/platforms', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deletePlatform(string $platformId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/platforms/' . $platformId, $headers); + } +} diff --git a/tests/e2e/Services/Project/PlatformsConsoleClientTest.php b/tests/e2e/Services/Project/PlatformsConsoleClientTest.php new file mode 100644 index 0000000000..9e6b841b00 --- /dev/null +++ b/tests/e2e/Services/Project/PlatformsConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test', 'scopes' => ['teams.read', 'teams.write'], @@ -151,6 +152,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'web', 'name' => 'Web App', @@ -163,6 +165,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-ios', 'name' => 'Flutter App (iOS)', @@ -175,6 +178,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-android', 'name' => 'Flutter App (Android)', @@ -187,6 +191,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-web', 'name' => 'Flutter App (Web)', @@ -199,6 +204,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-ios', 'name' => 'iOS App', @@ -211,6 +217,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-macos', 'name' => 'macOS App', @@ -223,6 +230,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-watchos', 'name' => 'watchOS App', @@ -235,6 +243,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-tvos', 'name' => 'tvOS App', diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 3f84529943..e0f94b64cc 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -62,6 +62,9 @@ class ProjectsConsoleClientTest extends Scope $this->assertArrayHasKey('platforms', $response['body']); $this->assertArrayHasKey('webhooks', $response['body']); $this->assertArrayHasKey('keys', $response['body']); + $this->assertEquals(false, $response['body']['authDisposableEmails']); + $this->assertEquals(false, $response['body']['authCanonicalEmails']); + $this->assertEquals(false, $response['body']['authFreeEmails']); $response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ 'content-type' => 'application/json', @@ -3158,6 +3161,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Custom', 'scopes' => ['teams.read', 'teams.write'], @@ -3243,6 +3247,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test 2', 'scopes' => ['users.read'], @@ -3618,6 +3623,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key For Deletion', 'scopes' => ['teams.read', 'teams.write'], @@ -3751,6 +3757,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'web', 'name' => 'Web App', @@ -3770,6 +3777,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-ios', 'name' => 'Flutter App (iOS)', @@ -3778,7 +3786,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('flutter-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly flutter-ios, but new version renames $this->assertEquals('Flutter App (iOS)', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3789,6 +3797,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-android', 'name' => 'Flutter App (Android)', @@ -3797,7 +3806,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('flutter-android', $response['body']['type']); + $this->assertEquals('android', $response['body']['type']); // Origianlly flutter-android, but new version renames $this->assertEquals('Flutter App (Android)', $response['body']['name']); $this->assertEquals('com.example.android', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3808,6 +3817,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-web', 'name' => 'Flutter App (Web)', @@ -3816,7 +3826,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('flutter-web', $response['body']['type']); + $this->assertEquals('web', $response['body']['type']); // Origianlly flutter-web, but new version renames $this->assertEquals('Flutter App (Web)', $response['body']['name']); $this->assertEquals('', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3827,6 +3837,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-ios', 'name' => 'iOS App', @@ -3835,7 +3846,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-ios, but new version renames $this->assertEquals('iOS App', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3846,6 +3857,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-macos', 'name' => 'macOS App', @@ -3854,7 +3866,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-macos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-macos, but new version renames $this->assertEquals('macOS App', $response['body']['name']); $this->assertEquals('com.example.macos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3865,6 +3877,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-watchos', 'name' => 'watchOS App', @@ -3873,7 +3886,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-watchos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-watchos, but new version renames $this->assertEquals('watchOS App', $response['body']['name']); $this->assertEquals('com.example.watchos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3884,6 +3897,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-tvos', 'name' => 'tvOS App', @@ -3892,7 +3906,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-tvos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-tvos, but new version renames $this->assertEquals('tvOS App', $response['body']['name']); $this->assertEquals('com.example.tvos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3906,6 +3920,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'unknown', 'name' => 'Web App', @@ -3924,6 +3939,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -3947,6 +3963,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -3963,12 +3980,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultteriOSId, $response['body']['$id']); - $this->assertEquals('flutter-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('Flutter App (iOS)', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3979,12 +3997,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterAndroidId, $response['body']['$id']); - $this->assertEquals('flutter-android', $response['body']['type']); + $this->assertEquals('android', $response['body']['type']); $this->assertEquals('Flutter App (Android)', $response['body']['name']); $this->assertEquals('com.example.android', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3995,12 +4014,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterWebId, $response['body']['$id']); - $this->assertEquals('flutter-web', $response['body']['type']); + $this->assertEquals('web', $response['body']['type']); $this->assertEquals('Flutter App (Web)', $response['body']['name']); $this->assertEquals('', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4011,12 +4031,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleIosId, $response['body']['$id']); - $this->assertEquals('apple-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('iOS App', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4027,12 +4048,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleMacOsId, $response['body']['$id']); - $this->assertEquals('apple-macos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('macOS App', $response['body']['name']); $this->assertEquals('com.example.macos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4043,12 +4065,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleWatchOsId, $response['body']['$id']); - $this->assertEquals('apple-watchos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('watchOS App', $response['body']['name']); $this->assertEquals('com.example.watchos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4059,12 +4082,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleTvOsId, $response['body']['$id']); - $this->assertEquals('apple-tvos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('tvOS App', $response['body']['name']); $this->assertEquals('com.example.tvos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4076,6 +4100,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/error', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4091,6 +4116,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Web App 2', 'hostname' => 'localhost-new', @@ -4110,6 +4136,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (iOS) 2', 'key' => 'com.example.ios2', @@ -4118,7 +4145,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultteriOSId, $response['body']['$id']); - $this->assertEquals('flutter-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly flutter-ios, but new version renames $this->assertEquals('Flutter App (iOS) 2', $response['body']['name']); $this->assertEquals('com.example.ios2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4129,6 +4156,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (Android) 2', 'key' => 'com.example.android2', @@ -4137,7 +4165,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterAndroidId, $response['body']['$id']); - $this->assertEquals('flutter-android', $response['body']['type']); + $this->assertEquals('android', $response['body']['type']); // Origianlly flutter-android, but new version renames $this->assertEquals('Flutter App (Android) 2', $response['body']['name']); $this->assertEquals('com.example.android2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4148,6 +4176,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (Web) 2', 'hostname' => 'flutter2.appwrite.io', @@ -4156,7 +4185,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterWebId, $response['body']['$id']); - $this->assertEquals('flutter-web', $response['body']['type']); + $this->assertEquals('web', $response['body']['type']); // Originally flutter-web, but new version renames $this->assertEquals('Flutter App (Web) 2', $response['body']['name']); $this->assertEquals('', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4167,6 +4196,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'iOS App 2', 'key' => 'com.example.ios2', @@ -4175,7 +4205,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleIosId, $response['body']['$id']); - $this->assertEquals('apple-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-ios, but new version renames $this->assertEquals('iOS App 2', $response['body']['name']); $this->assertEquals('com.example.ios2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4186,6 +4216,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'macOS App 2', 'key' => 'com.example.macos2', @@ -4194,7 +4225,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleMacOsId, $response['body']['$id']); - $this->assertEquals('apple-macos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-macos, but new version renames $this->assertEquals('macOS App 2', $response['body']['name']); $this->assertEquals('com.example.macos2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4205,6 +4236,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'watchOS App 2', 'key' => 'com.example.watchos2', @@ -4213,7 +4245,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleWatchOsId, $response['body']['$id']); - $this->assertEquals('apple-watchos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-watchos, but new version renames $this->assertEquals('watchOS App 2', $response['body']['name']); $this->assertEquals('com.example.watchos2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4224,6 +4256,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'tvOS App 2', 'key' => 'com.example.tvos2', @@ -4232,7 +4265,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleTvOsId, $response['body']['$id']); - $this->assertEquals('apple-tvos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-tvos, but new version renames $this->assertEquals('tvOS App 2', $response['body']['name']); $this->assertEquals('com.example.tvos2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4244,6 +4277,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/error', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (Android) 2', 'key' => 'com.example.android2', @@ -4262,6 +4296,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'web', 'name' => 'Web App', @@ -4274,6 +4309,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-ios', 'name' => 'Flutter App (iOS)', @@ -4286,6 +4322,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-android', 'name' => 'Flutter App (Android)', @@ -4298,6 +4335,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-web', 'name' => 'Flutter App (Web)', @@ -4310,6 +4348,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-ios', 'name' => 'iOS App', @@ -4322,6 +4361,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-macos', 'name' => 'macOS App', @@ -4334,6 +4374,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-watchos', 'name' => 'watchOS App', @@ -4346,6 +4387,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-tvos', 'name' => 'tvOS App', @@ -4357,6 +4399,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4365,6 +4408,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4372,6 +4416,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4380,6 +4425,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4387,6 +4433,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4395,6 +4442,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4402,6 +4450,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4410,6 +4459,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4417,6 +4467,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4425,6 +4476,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4432,6 +4484,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4440,6 +4493,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4447,6 +4501,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4455,6 +4510,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4462,6 +4518,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4470,6 +4527,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index f6200ed209..9c768f00d1 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3039,8 +3039,21 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals(200, $update['headers']['status-code']); - $event = json_decode($client->receive(), true); + // Drain WebSocket messages until the .update event arrives. + // Earlier events (e.g. a late-arriving .create from the row seed above) are skipped. + $updateEvent = "tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update"; + $event = null; + $deadline = \time() + 10; + while (\time() < $deadline) { + $raw = $client->receive(); + $msg = json_decode($raw, true); + if (($msg['type'] ?? '') === 'event' && \in_array($updateEvent, $msg['data']['events'] ?? [])) { + $event = $msg; + break; + } + } + $this->assertNotNull($event, 'Timed out waiting for the row update event'); $this->assertArrayHasKey('type', $event); $this->assertArrayHasKey('data', $event); $this->assertEquals('event', $event['type']); @@ -5248,4 +5261,154 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + + public function testChannelDatabaseAtomicOperations() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Test Database Create + + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Atomic DB', + ]); + $databaseId = $database['body']['$id']; + + //Test Collection Create + + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Atomic Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $actorsId = $actors['body']['$id']; + + //Test Attribute Create + + $scoreAttr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $actorsId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/score', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + //Test Document Create + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 10 + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $documentId = $document['body']['$id']; + + $client->receive(); + + // Test Document Increment + $increment = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId . '/score/increment', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'value' => 5 + ]); + + $this->assertEquals(200, $increment['headers']['status-code']); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals(15, $response['data']['payload']['score']); + + sleep(1); + + try { + $client->receive(); + $this->fail('Should not receive duplicate event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Test Document Decrement + $decrement = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId . '/score/decrement', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'value' => 3 + ]); + + $this->assertEquals(200, $decrement['headers']['status-code']); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals(12, $response['data']['payload']['score']); + + sleep(1); + + try { + $client->receive(); + $this->fail('Should not receive duplicate event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } } diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 100f5c5ca8..d1cb548016 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -97,6 +97,7 @@ trait StorageBase 'x-appwrite-project' => $this->getProject()['$id'] ]; $id = ''; + $largeFile = null; while (!feof($handle)) { $curlFile = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(@fread($handle, $chunkSize)), $mimeType, 'large-file.mp4'); $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size - 1) . '/' . $size; @@ -132,7 +133,7 @@ trait StorageBase self::$cachedBucketFile[$cacheKey] = [ 'bucketId' => $bucketId, 'fileId' => $file['body']['$id'], - 'largeFileId' => $largeFile['body']['$id'], + 'largeFileId' => $largeFile['body']['$id'] ?? '', 'largeBucketId' => $bucket2['body']['$id'], 'webpFileId' => $webpFile['body']['$id'] ]; @@ -261,6 +262,7 @@ trait StorageBase 'x-appwrite-project' => $this->getProject()['$id'] ]; $id = ''; + $largeFile = null; while (!feof($handle)) { $curlFile = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(@fread($handle, $chunkSize)), $mimeType, 'large-file.mp4'); $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size - 1) . '/' . $size; diff --git a/tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php b/tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php index 44af63fb22..b7fd982a6d 100644 --- a/tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php +++ b/tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php @@ -29,8 +29,8 @@ class DatabasesStringTypesTest extends Scope protected function setupDatabaseAndTable(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$setupCache[$cacheKey])) { - return static::$setupCache[$cacheKey]; + if (!empty(self::$setupCache[$cacheKey])) { + return self::$setupCache[$cacheKey]; } $projectId = $this->getProject()['$id']; @@ -131,7 +131,7 @@ class DatabasesStringTypesTest extends Scope // Cache before waiting so that if waitForAllAttributes times out, // subsequent calls don't try to re-create the same columns (causing 409) - static::$setupCache[$cacheKey] = [ + self::$setupCache[$cacheKey] = [ 'databaseId' => $databaseId, 'tableId' => $tableId, ]; @@ -139,7 +139,7 @@ class DatabasesStringTypesTest extends Scope // Wait for all columns to be available $this->waitForAllAttributes($databaseId, $tableId); - return static::$setupCache[$cacheKey]; + return self::$setupCache[$cacheKey]; } public function testCreateDatabase(): void diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index ced6bb5dde..1fcdeb347f 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -14,8 +14,8 @@ trait TokensBase protected function setupBucketAndFile(): array { - if (!empty(static::$bucketAndFileData)) { - return static::$bucketAndFileData; + if (!empty(self::$bucketAndFileData)) { + return self::$bucketAndFileData; } $bucket = $this->client->call( @@ -61,7 +61,7 @@ trait TokensBase ] ); - static::$bucketAndFileData = [ + self::$bucketAndFileData = [ 'fileId' => $fileId, 'bucketId' => $bucketId, 'token' => $token['body'], @@ -72,7 +72,7 @@ trait TokensBase ], ]; - return static::$bucketAndFileData; + return self::$bucketAndFileData; } public function testCreateBucketAndFile(): void diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index b7f188f5b5..601bf1d2d0 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -25,8 +25,8 @@ class TokensConsoleClientTest extends Scope protected function setupToken(): array { - if (!empty(static::$tokenData)) { - return static::$tokenData; + if (!empty(self::$tokenData)) { + return self::$tokenData; } $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ @@ -68,13 +68,13 @@ class TokensConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders())); - static::$tokenData = [ + self::$tokenData = [ 'fileId' => $fileId, 'bucketId' => $bucketId, 'tokenId' => $token['body']['$id'], ]; - return static::$tokenData; + return self::$tokenData; } public function testCreateToken(): void diff --git a/tests/e2e/Services/Tokens/TokensCustomServerTest.php b/tests/e2e/Services/Tokens/TokensCustomServerTest.php index ecb9bafc89..3efa0adbe1 100644 --- a/tests/e2e/Services/Tokens/TokensCustomServerTest.php +++ b/tests/e2e/Services/Tokens/TokensCustomServerTest.php @@ -22,8 +22,8 @@ class TokensCustomServerTest extends Scope protected function setupToken(): array { - if (!empty(static::$tokenData)) { - return static::$tokenData; + if (!empty(self::$tokenData)) { + return self::$tokenData; } $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ @@ -66,13 +66,13 @@ class TokensCustomServerTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders())); - static::$tokenData = [ + self::$tokenData = [ 'fileId' => $fileId, 'bucketId' => $bucketId, 'tokenId' => $token['body']['$id'], ]; - return static::$tokenData; + return self::$tokenData; } public function testCreateToken(): void diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 940746a8eb..3255d9a67f 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -28,8 +28,8 @@ trait UsersBase protected function setupUser(): array { $projectId = $this->getProject()['$id']; - if (!empty(static::$cachedUser[$projectId])) { - return static::$cachedUser[$projectId]; + if (!empty(self::$cachedUser[$projectId])) { + return self::$cachedUser[$projectId]; } $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ @@ -52,16 +52,16 @@ trait UsersBase ]); if (!empty($response['body']['users'])) { - static::$cachedUser[$projectId] = ['userId' => $response['body']['users'][0]['$id']]; - return static::$cachedUser[$projectId]; + self::$cachedUser[$projectId] = ['userId' => $response['body']['users'][0]['$id']]; + return self::$cachedUser[$projectId]; } } if ($user['headers']['status-code'] === 201) { - static::$cachedUser[$projectId] = ['userId' => $user['body']['$id']]; + self::$cachedUser[$projectId] = ['userId' => $user['body']['$id']]; } - return static::$cachedUser[$projectId]; + return self::$cachedUser[$projectId]; } /** @@ -90,7 +90,7 @@ trait UsersBase protected function setupHashedPasswordUsers(): void { $projectId = $this->getProject()['$id']; - if (!empty(static::$cachedHashedPasswordUsers[$projectId])) { + if (!empty(self::$cachedHashedPasswordUsers[$projectId])) { return; } @@ -180,7 +180,7 @@ trait UsersBase 'passwordSignerKey' => 'XyEKE9RcTDeLEsL/RjwPDBv/RqDl8fb3gpYEOQaPihbxf1ZAtSOHCjuAAa7Q3oHpCYhXSN9tizHgVOwn6krflQ==', ]); - static::$cachedHashedPasswordUsers[$projectId] = true; + self::$cachedHashedPasswordUsers[$projectId] = true; } /** @@ -189,8 +189,8 @@ trait UsersBase protected function setupUserTarget(): array { $projectId = $this->getProject()['$id']; - if (!empty(static::$cachedUserTarget[$projectId])) { - return static::$cachedUserTarget[$projectId]; + if (!empty(self::$cachedUserTarget[$projectId])) { + return self::$cachedUserTarget[$projectId]; } $data = $this->setupUser(); @@ -233,10 +233,10 @@ trait UsersBase ]); if ($response['headers']['status-code'] === 201) { - static::$cachedUserTarget[$projectId] = $response['body']; + self::$cachedUserTarget[$projectId] = $response['body']; } - return static::$cachedUserTarget[$projectId] ?? []; + return self::$cachedUserTarget[$projectId] ?? []; } /** @@ -247,7 +247,7 @@ trait UsersBase $data = $this->setupUser(); $projectId = $this->getProject()['$id']; - if (static::$userNameUpdated) { + if (self::$userNameUpdated) { return $data; } @@ -258,7 +258,7 @@ trait UsersBase 'name' => 'Updated name', ]); - static::$userNameUpdated = true; + self::$userNameUpdated = true; return $data; } @@ -270,7 +270,7 @@ trait UsersBase $data = $this->setupUser(); $projectId = $this->getProject()['$id']; - if (static::$userEmailUpdated) { + if (self::$userEmailUpdated) { return $data; } @@ -281,7 +281,7 @@ trait UsersBase 'email' => 'users.service@updated.com', ]); - static::$userEmailUpdated = true; + self::$userEmailUpdated = true; return $data; } @@ -293,7 +293,7 @@ trait UsersBase $data = $this->setupUser(); $projectId = $this->getProject()['$id']; - if (static::$userNumberUpdated) { + if (self::$userNumberUpdated) { return $data; } @@ -304,7 +304,7 @@ trait UsersBase 'number' => '+910000000000', ]); - static::$userNumberUpdated = true; + self::$userNumberUpdated = true; return $data; } @@ -474,7 +474,7 @@ trait UsersBase // Cache the user ID for other tests $projectId = $this->getProject()['$id']; - static::$cachedUser[$projectId] = ['userId' => $body['$id']]; + self::$cachedUser[$projectId] = ['userId' => $body['$id']]; } /** @@ -1274,7 +1274,7 @@ trait UsersBase $this->assertEquals($user['body']['name'], 'Updated name'); // Mark name as updated for search tests - static::$userNameUpdated = true; + self::$userNameUpdated = true; } public function testUpdateUserNameSearch(): void @@ -1357,7 +1357,7 @@ trait UsersBase $this->assertEquals($user['body']['email'], 'users.service@updated.com'); // Mark email as updated for search tests - static::$userEmailUpdated = true; + self::$userEmailUpdated = true; } public function testUpdateUserEmailSearch(): void @@ -1645,7 +1645,7 @@ trait UsersBase $this->assertEquals($response['body']['type'], $errorType); // Mark phone as updated for search tests - static::$userNumberUpdated = true; + self::$userNumberUpdated = true; } public function testUpdateTwoUsersPhoneToEmpty(): void @@ -1954,7 +1954,7 @@ trait UsersBase // Cache for other tests $projectId = $this->getProject()['$id']; - static::$cachedUserTarget[$projectId] = $response['body']; + self::$cachedUserTarget[$projectId] = $response['body']; } public function testUpdateUserTarget(): void @@ -1973,7 +1973,7 @@ trait UsersBase // Update cache with new data $projectId = $this->getProject()['$id']; - static::$cachedUserTarget[$projectId] = $response['body']; + self::$cachedUserTarget[$projectId] = $response['body']; } public function testListUserTarget(): void @@ -2014,7 +2014,7 @@ trait UsersBase // Clear cached target since it was deleted $projectId = $this->getProject()['$id']; - unset(static::$cachedUserTarget[$projectId]); + unset(self::$cachedUserTarget[$projectId]); $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/targets', array_merge([ 'content-type' => 'application/json', diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php index b1b7e9a42a..854e7110f1 100644 --- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php +++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php @@ -37,8 +37,8 @@ class VCSConsoleClientTest extends Scope { $projectId = $this->getProject()['$id']; - if (!empty(static::$cachedInstallationId[$projectId])) { - return static::$cachedInstallationId[$projectId]; + if (!empty(self::$cachedInstallationId[$projectId])) { + return self::$cachedInstallationId[$projectId]; } $response = $this->client->call(Client::METHOD_GET, '/mock/github/callback', array_merge([ @@ -48,8 +48,8 @@ class VCSConsoleClientTest extends Scope 'projectId' => $projectId, ]); - static::$cachedInstallationId[$projectId] = $response['body']['installationId']; - return static::$cachedInstallationId[$projectId]; + self::$cachedInstallationId[$projectId] = $response['body']['installationId']; + return self::$cachedInstallationId[$projectId]; } /** @@ -60,8 +60,8 @@ class VCSConsoleClientTest extends Scope { $projectId = $this->getProject()['$id']; - if (!empty(static::$cachedFunctionData[$projectId])) { - return static::$cachedFunctionData[$projectId]; + if (!empty(self::$cachedFunctionData[$projectId])) { + return self::$cachedFunctionData[$projectId]; } $installationId = $this->setupInstallation(); @@ -86,12 +86,12 @@ class VCSConsoleClientTest extends Scope 'providerBranch' => 'main', ]); - static::$cachedFunctionData[$projectId] = [ + self::$cachedFunctionData[$projectId] = [ 'installationId' => $installationId, 'functionId' => $function['body']['$id'] ]; - return static::$cachedFunctionData[$projectId]; + return self::$cachedFunctionData[$projectId]; } public function testGitHubAuthorize(): void diff --git a/tests/resources/json/documents-internals.json b/tests/resources/json/documents-internals.json new file mode 100644 index 0000000000..6fe6820cdf --- /dev/null +++ b/tests/resources/json/documents-internals.json @@ -0,0 +1,254 @@ +[ + { + "$id": "z1y2x3w4v5u6t7s8", + "$createdAt": "2022-10-23T10:33:01+00:00", + "$updatedAt": "2023-03-15T12:00:41+00:00", + "$permissions": [ + "read(\"any\")", + "update(\"user:123\")" + ], + "name": "Diamond Mendez", + "age": 56 + }, + { + "$id": "r9q0p1o2n3m4l5k6", + "$createdAt": "2021-08-11T21:05:13+00:00", + "$updatedAt": "2024-01-02T08:45:22+00:00", + "$permissions": [ + "read(\"any\")", + "update(\"user:456\")" + ], + "name": "Michael Huff", + "age": 20 + }, + { + "$id": "j7i8h9g0f1e2d3c4", + "$createdAt": "2020-05-29T14:22:56+00:00", + "$updatedAt": "2022-11-30T18:19:33+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Alyssa Rodriguez", + "age": 37 + }, + { + "$id": "b5a6z7y8x9w0v1u2", + "$createdAt": "2023-01-18T03:44:09+00:00", + "$updatedAt": "2023-09-07T23:50:17+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Barbara Smith", + "age": 26 + }, + { + "$id": "t3s4r5q6p7o8n9m0", + "$createdAt": "2020-11-02T09:12:45+00:00", + "$updatedAt": "2021-07-21T15:30:55+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Evelyn Edwards", + "age": 54 + }, + { + "$id": "l1k2j3i4h5g6f7e8", + "$createdAt": "2022-03-19T19:55:27+00:00", + "$updatedAt": "2024-05-14T06:28:11+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Tina Richardson", + "age": 41 + }, + { + "$id": "d9c0b1a2z3y4x5w6", + "$createdAt": "2021-04-07T01:18:34+00:00", + "$updatedAt": "2023-06-25T11:47:04+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Joel Hernandez", + "age": 49 + }, + { + "$id": "v7u8t9s0r1q2p3o4", + "$createdAt": "2023-08-22T16:40:18+00:00", + "$updatedAt": "2024-02-19T04:09:58+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Zachary Cooper", + "age": 59 + }, + { + "$id": "n5m6l7k8j9i0h1g2", + "$createdAt": "2020-02-12T07:59:01+00:00", + "$updatedAt": "2022-09-08T13:21:49+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Brittany Spears", + "age": 20 + }, + { + "$id": "f3e4d5c6b7a8z9y0", + "$createdAt": "2021-12-05T22:33:12+00:00", + "$updatedAt": "2023-11-11T02:55:37+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Holly White", + "age": 47 + }, + { + "$id": "x1w2v3u4t5s6r7q8", + "$createdAt": "2022-07-14T05:01:50+00:00", + "$updatedAt": "2024-04-01T20:10:26+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Kimberly Barnes", + "age": 27 + }, + { + "$id": "p9o0n1m2l3k4j5i6", + "$createdAt": "2020-09-28T11:27:36+00:00", + "$updatedAt": "2021-10-17T09:38:08+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Stephen Miller", + "age": 53 + }, + { + "$id": "h7g8f9e0d1c2b3a4", + "$createdAt": "2023-04-04T08:15:59+00:00", + "$updatedAt": "2024-06-29T17:03:14+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Yvonne Newman", + "age": 41 + }, + { + "$id": "y5x6w7v8u9t0s1r2", + "$createdAt": "2021-01-25T18:09:21+00:00", + "$updatedAt": "2022-08-16T22:44:51+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Carol Kane", + "age": 38 + }, + { + "$id": "q3p4o5n6m7l8k9j0", + "$createdAt": "2022-06-09T12:53:47+00:00", + "$updatedAt": "2023-12-24T01:16:05+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Doris Foster", + "age": 44 + }, + { + "$id": "i1h2g3f4e5d6c7b8", + "$createdAt": "2020-07-03T23:37:02+00:00", + "$updatedAt": "2021-05-09T05:52:43+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Joseph Stokes", + "age": 28 + }, + { + "$id": "a9z0y1x2w3v4u5t6", + "$createdAt": "2023-10-10T02:20:15+00:00", + "$updatedAt": "2024-03-28T14:33:29+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Steve Williams", + "age": 31 + }, + { + "$id": "s7r8q9p0o1n2m3l4", + "$createdAt": "2021-06-16T13:48:53+00:00", + "$updatedAt": "2022-04-22T07:07:19+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "James Carey", + "age": 29 + }, + { + "$id": "k5j6i7h8g9f0e1d2", + "$createdAt": "2022-12-27T20:06:38+00:00", + "$updatedAt": "2023-08-03T10:25:57+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Kathryn Henry", + "age": 38 + }, + { + "$id": "c3b4a5z6y7x8w9v0", + "$createdAt": "2020-04-20T04:41:24+00:00", + "$updatedAt": "2021-02-13T19:14:06+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Christopher Landry", + "age": 23 + }, + { + "$id": "u1t2s3r4q5p6o7n8", + "$createdAt": "2023-05-08T00:58:10+00:00", + "$updatedAt": "2024-07-05T03:36:48+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Jennifer Mcgee", + "age": 62 + }, + { + "$id": "m9l0k1j2i3h4g5f6", + "$createdAt": "2021-09-01T06:11:42+00:00", + "$updatedAt": "2022-01-26T16:59:23+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Cathy Church", + "age": 35 + }, + { + "$id": "e7d8c9b0a1z2y3x4", + "$createdAt": "2022-02-18T15:24:07+00:00", + "$updatedAt": "2023-04-12T00:40:31+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Jose Lopez", + "age": 41 + }, + { + "$id": "w5v6u7t8s9r0q1p2", + "$createdAt": "2020-12-13T09:03:55+00:00", + "$updatedAt": "2021-11-06T11:23:16+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "William Rose", + "age": 30 + }, + { + "$id": "o3n4m5l6k7j8i9h0", + "$createdAt": "2021-12-13T09:03:55+00:00", + "$updatedAt": "2022-11-06T11:23:16+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Charles Hammer", + "age": 61 + } +] \ No newline at end of file diff --git a/tests/resources/json/documents.json b/tests/resources/json/documents.json new file mode 100644 index 0000000000..1ce5533297 --- /dev/null +++ b/tests/resources/json/documents.json @@ -0,0 +1,502 @@ +[ + { + "$id": "hxfcwpcas5xokpwe", + "name": "Diamond Mendez", + "age": 56 + }, + { + "$id": "gw8nxwf6esn3tfwf", + "name": "Michael Huff", + "age": 20 + }, + { + "$id": "xb6bxg56lral1qy9", + "name": "Alyssa Rodriguez", + "age": 37 + }, + { + "$id": "imerjq5j36y3agh2", + "name": "Barbara Smith", + "age": 26 + }, + { + "$id": "07yq9qdlhmbzmr35", + "name": "Evelyn Edwards", + "age": 54 + }, + { + "$id": "ksqo631sbhwj5ltg", + "name": "Tina Richardson", + "age": 41 + }, + { + "$id": "j7zlndgu0gbshp15", + "name": "Joel Hernandez", + "age": 49 + }, + { + "$id": "mfntvnljrcmf7h6v", + "name": "Zachary Cooper", + "age": 59 + }, + { + "$id": "5f9b01nziqu2h8ed", + "name": "Brittany Spears", + "age": 20 + }, + { + "$id": "4vxzbnzraqznk5u8", + "name": "Holly White", + "age": 47 + }, + { + "$id": "d4ywy3mtphaatbpf", + "name": "Kimberly Barnes", + "age": 27 + }, + { + "$id": "88odnk6nthyyvbal", + "name": "Stephen Miller", + "age": 53 + }, + { + "$id": "08oekee3fn7mzaa5", + "name": "Yvonne Newman", + "age": 41 + }, + { + "$id": "quw55kn9895i5e4v", + "name": "Carol Kane", + "age": 38 + }, + { + "$id": "nge6bm8ykripei6f", + "name": "Doris Foster", + "age": 44 + }, + { + "$id": "4k16i33s0xl2ypx9", + "name": "Joseph Stokes", + "age": 28 + }, + { + "$id": "q0j5rxbgid66snyf", + "name": "Steve Williams", + "age": 31 + }, + { + "$id": "n1oxun7mqq3p103y", + "name": "James Carey", + "age": 29 + }, + { + "$id": "0dbvs840jkf8i0ye", + "name": "Kathryn Henry", + "age": 38 + }, + { + "$id": "5sfaidgs1h87v15v", + "name": "Christopher Landry", + "age": 23 + }, + { + "$id": "vg3punvfu5khmf41", + "name": "Jennifer Mcgee", + "age": 62 + }, + { + "$id": "f933qydr9u5b2r11", + "name": "Cathy Church", + "age": 35 + }, + { + "$id": "wjv87y1inf8yk32s", + "name": "Jose Lopez", + "age": 41 + }, + { + "$id": "uljysdvdlcyrbrwk", + "name": "William Rose", + "age": 30 + }, + { + "$id": "ot8xtzh77j55wq0s", + "name": "Sarah Ford", + "age": 26 + }, + { + "$id": "9t76vnsv2u36s43t", + "name": "Alisha Jones", + "age": 61 + }, + { + "$id": "66y4tnty62hw8c02", + "name": "Kristin Kelly", + "age": 61 + }, + { + "$id": "2punfblazi5v16ar", + "name": "Brendan Stout", + "age": 40 + }, + { + "$id": "sxhr4nf5w2gx4wbg", + "name": "Kelly Cruz", + "age": 18 + }, + { + "$id": "68dvrqfwqnkq5el9", + "name": "Samantha Martin", + "age": 50 + }, + { + "$id": "20192l6dbeinhkh0", + "name": "David Santos", + "age": 46 + }, + { + "$id": "si0l4dgay09ebfmf", + "name": "Elizabeth Carroll", + "age": 22 + }, + { + "$id": "lhse40vbldqb6ap1", + "name": "Corey Owens", + "age": 46 + }, + { + "$id": "h5t3pslykyx3kxfm", + "name": "Shelby Mueller", + "age": 65 + }, + { + "$id": "ldc0luydrw6jub0f", + "name": "Dr. Sylvia Myers", + "age": 29 + }, + { + "$id": "voc9628xg4dsgw2y", + "name": "Scott Freeman", + "age": 48 + }, + { + "$id": "o4y0gk3gqv1ax2fz", + "name": "Christopher Atkinson", + "age": 21 + }, + { + "$id": "u1n3x4e4u7e0vzj6", + "name": "Sean Diaz", + "age": 31 + }, + { + "$id": "s36eskwtm0w7lwr7", + "name": "Bobby Dyer", + "age": 57 + }, + { + "$id": "4hjnag1p5iwvtixd", + "name": "Daniel Hall", + "age": 62 + }, + { + "$id": "m91d80oxsa216zbh", + "name": "Jennifer Ramirez", + "age": 65 + }, + { + "$id": "5hj6858zo2g85n6v", + "name": "Angela Jackson", + "age": 57 + }, + { + "$id": "8m8oihv9a1e7nn92", + "name": "Kelly Lewis", + "age": 36 + }, + { + "$id": "7azy39la0no0mxi7", + "name": "Jessica Munoz", + "age": 55 + }, + { + "$id": "47pmjkhnnqhyit8c", + "name": "Kelly George", + "age": 65 + }, + { + "$id": "6j6cpy4kgneg1mmh", + "name": "Anthony Johnson", + "age": 65 + }, + { + "$id": "tnlmtvap1zz89km9", + "name": "Regina Fields", + "age": 61 + }, + { + "$id": "6cyuvnwwqdmrpfzh", + "name": "Sharon Schaefer", + "age": 30 + }, + { + "$id": "p1v4pyu2pqodc0ey", + "name": "Jacob French", + "age": 62 + }, + { + "$id": "6npynnhjt2jd05xo", + "name": "Jessica Costa", + "age": 23 + }, + { + "$id": "wcxedf13n2e9qi4l", + "name": "George Hardy", + "age": 53 + }, + { + "$id": "yf2xlcmszk2tqeig", + "name": "Andrea Allison", + "age": 20 + }, + { + "$id": "3bf2zzv7poststwa", + "name": "Kevin Ferguson", + "age": 32 + }, + { + "$id": "c2iataz0hhv39q63", + "name": "Joseph Johnson", + "age": 58 + }, + { + "$id": "3e8npxhov4a39pvq", + "name": "Ashley Martinez", + "age": 18 + }, + { + "$id": "t7dp41tysipytywq", + "name": "Charles Nixon", + "age": 23 + }, + { + "$id": "z8cztq7c47phyfhk", + "name": "Carol Dudley", + "age": 40 + }, + { + "$id": "2636f9d8r4ipm3h6", + "name": "David Weber", + "age": 51 + }, + { + "$id": "eh3f6wxtvkjq6ykq", + "name": "Scott Robinson", + "age": 32 + }, + { + "$id": "raskbwpsje69a59h", + "name": "Anthony Hardy", + "age": 38 + }, + { + "$id": "90hn1p0b4cs9e2og", + "name": "Mackenzie Owens", + "age": 52 + }, + { + "$id": "am3swwfbo076x0v1", + "name": "Brian Foster", + "age": 27 + }, + { + "$id": "5uw7utb9lq5cfncw", + "name": "Hannah Forbes", + "age": 56 + }, + { + "$id": "cs6mbfzkzifefx6r", + "name": "Lauren Reed", + "age": 26 + }, + { + "$id": "ftw3uvztziiz9x00", + "name": "Morgan Smith", + "age": 28 + }, + { + "$id": "uhrqseeo43mozpaq", + "name": "Samantha Alexander", + "age": 65 + }, + { + "$id": "pvvmzyfc1lxor11e", + "name": "Tiffany Roberts", + "age": 20 + }, + { + "$id": "jia7bdag4abz123s", + "name": "Emily Hayes", + "age": 34 + }, + { + "$id": "h6oozcngbz8o5x4y", + "name": "Rebecca Villegas", + "age": 52 + }, + { + "$id": "9v6z1pn2f9twcy12", + "name": "Donald Shah", + "age": 61 + }, + { + "$id": "wzz3jduioso77o7f", + "name": "Denise Cain", + "age": 59 + }, + { + "$id": "u51plhgvjodkswnr", + "name": "Kristine Ramirez", + "age": 53 + }, + { + "$id": "t1uhkmiytfyc13vc", + "name": "Stacey Adkins", + "age": 61 + }, + { + "$id": "iqaqnf0ybg2ct507", + "name": "Daniel Hunt", + "age": 20 + }, + { + "$id": "idwrwv2uu4hcpv2i", + "name": "Roberta Johnson", + "age": 48 + }, + { + "$id": "2yd2hd6auetjacyo", + "name": "Jason Williamson", + "age": 39 + }, + { + "$id": "egrmdbibnjhi914x", + "name": "Sandra Robinson", + "age": 50 + }, + { + "$id": "15m1pz2bb0ercgyk", + "name": "Steve Rice", + "age": 25 + }, + { + "$id": "0i21bhkxdagjurb7", + "name": "Kimberly Fritz", + "age": 53 + }, + { + "$id": "726ofi7h5snreq67", + "name": "Brianna Reynolds", + "age": 33 + }, + { + "$id": "csqxse3wym56eim6", + "name": "Alexander Williams", + "age": 50 + }, + { + "$id": "qeaoylnrsf8p3byg", + "name": "Andrew Thomas", + "age": 25 + }, + { + "$id": "edsswobumzyzbvhf", + "name": "Austin Williams", + "age": 57 + }, + { + "$id": "hdzhzpt0ahy5hkib", + "name": "Nicholas Williams", + "age": 24 + }, + { + "$id": "w1qmvmg4roa8xnwu", + "name": "Mrs. Michelle Cisneros", + "age": 48 + }, + { + "$id": "3z3o73x7adyuo6w0", + "name": "Stacey Smith", + "age": 39 + }, + { + "$id": "sse2u5zlgoqrgmcf", + "name": "Laura Beck", + "age": 20 + }, + { + "$id": "rvovijmvch58r4yx", + "name": "Molly Clark", + "age": 51 + }, + { + "$id": "doe06nrx8sg5mcuv", + "name": "Carmen Morris", + "age": 41 + }, + { + "$id": "jbjdwuvj5s4kw04y", + "name": "Amanda Munoz", + "age": 20 + }, + { + "$id": "6k2ewkla7js0yw23", + "name": "Rachel Collins", + "age": 44 + }, + { + "$id": "fcxuyr4kkhrnigu1", + "name": "John Alexander", + "age": 18 + }, + { + "$id": "d25fuwlos5mk07o0", + "name": "Stacy Hunter", + "age": 22 + }, + { + "$id": "1vdai2rxmwd57oet", + "name": "Eric Massey", + "age": 40 + }, + { + "$id": "pq4jnt9izu1wlrzd", + "name": "Scott Garcia", + "age": 20 + }, + { + "$id": "lz9kfc0lty5xcz14", + "name": "Cassandra Nelson", + "age": 35 + }, + { + "$id": "pu7w6tyab5jd4we9", + "name": "Aaron Johnson", + "age": 50 + }, + { + "$id": "8dupswd2kqwdyn8v", + "name": "Shannon Sherman", + "age": 45 + }, + { + "$id": "ye466l71jthiz2p6", + "name": "April Garcia", + "age": 60 + }, + { + "$id": "xogsmfwb73l16qdt", + "name": "Evan Lynn", + "age": 20 + } +] \ No newline at end of file diff --git a/tests/resources/json/documentsdb-documents.json b/tests/resources/json/documentsdb-documents.json new file mode 100644 index 0000000000..908c123e81 --- /dev/null +++ b/tests/resources/json/documentsdb-documents.json @@ -0,0 +1,162 @@ +[ + { + "$id": "doc01", + "name": "Alice", + "email": "alice@test.com", + "age": 35, + "active": false, + "tags": [ + "analyst", + "engineer" + ], + "address": { + "city": "New York", + "zip": "27436", + "country": "DE" + } + }, + { + "$id": "doc02", + "name": "Bob", + "email": "bob@test.com", + "age": 58, + "active": false, + "tags": [ + "engineer", + "manager" + ], + "address": { + "city": "London", + "zip": "49320", + "country": "JP" + } + }, + { + "$id": "doc03", + "name": "Charlie", + "email": "charlie@test.com", + "age": 23, + "active": true, + "tags": [ + "analyst", + "manager" + ], + "address": { + "city": "Tokyo", + "zip": "74758", + "country": "JP" + } + }, + { + "$id": "doc04", + "name": "Diana", + "email": "diana@test.com", + "age": 60, + "active": true, + "tags": [ + "analyst", + "manager" + ], + "address": { + "city": "Paris", + "zip": "50703", + "country": "JP" + } + }, + { + "$id": "doc05", + "name": "Eve", + "email": "eve@test.com", + "age": 59, + "active": false, + "tags": [ + "engineer", + "designer" + ], + "address": { + "city": "Berlin", + "zip": "98929", + "country": "UK" + } + }, + { + "$id": "doc06", + "name": "Frank", + "email": "frank@test.com", + "age": 23, + "active": true, + "tags": [ + "engineer", + "manager" + ], + "address": { + "city": "Sydney", + "zip": "82907", + "country": "US" + } + }, + { + "$id": "doc07", + "name": "Grace", + "email": "grace@test.com", + "age": 36, + "active": true, + "tags": [ + "analyst", + "developer" + ], + "address": { + "city": "Toronto", + "zip": "68710", + "country": "US" + } + }, + { + "$id": "doc08", + "name": "Hank", + "email": "hank@test.com", + "age": 28, + "active": false, + "tags": [ + "engineer", + "manager" + ], + "address": { + "city": "Mumbai", + "zip": "61026", + "country": "FR" + } + }, + { + "$id": "doc09", + "name": "Iris", + "email": "iris@test.com", + "age": 40, + "active": true, + "tags": [ + "developer", + "designer" + ], + "address": { + "city": "Seoul", + "zip": "48039", + "country": "FR" + } + }, + { + "$id": "doc10", + "name": "Jack", + "email": "jack@test.com", + "age": 45, + "active": false, + "tags": [ + "manager", + "developer" + ], + "address": { + "city": "Dubai", + "zip": "22733", + "country": "JP" + } + } +] \ No newline at end of file diff --git a/tests/resources/json/irrelevant-column.json b/tests/resources/json/irrelevant-column.json new file mode 100644 index 0000000000..d047620ab0 --- /dev/null +++ b/tests/resources/json/irrelevant-column.json @@ -0,0 +1,602 @@ +[ + { + "$id": "r5ctmrqwqn1m3rc0", + "name": "Diamond Mendez", + "age": 56, + "email": "diamond.mendez@example.com" + }, + { + "$id": "wxwp7e7q7nx3ltfx", + "name": "Michael Huff", + "age": 20, + "email": "michael.huff@example.com" + }, + { + "$id": "4ct0b38fwaojawlv", + "name": "Alyssa Rodriguez", + "age": 37, + "email": "alyssa.rodriguez@example.com" + }, + { + "$id": "o0jjcuygbta2zvga", + "name": "Barbara Smith", + "age": 26, + "email": "barbara.smith@example.com" + }, + { + "$id": "bdy6l2ofl8klb4pb", + "name": "Evelyn Edwards", + "age": 54, + "email": "evelyn.edwards@example.com" + }, + { + "$id": "rkccl72v7zwtbila", + "name": "Tina Richardson", + "age": 41, + "email": "tina.richardson@example.com" + }, + { + "$id": "cilw7um0cd927esj", + "name": "Joel Hernandez", + "age": 49, + "email": "joel.hernandez@example.com" + }, + { + "$id": "60povvz0votkve1j", + "name": "Zachary Cooper", + "age": 59, + "email": "zachary.cooper@example.com" + }, + { + "$id": "ayow5dzwktvbbtp2", + "name": "Brittany Spears", + "age": 20, + "email": "brittany.spears@example.com" + }, + { + "$id": "cfru98od0lab0b2n", + "name": "Holly White", + "age": 47, + "email": "holly.white@example.com" + }, + { + "$id": "vjxjldvu3r6uylq6", + "name": "Kimberly Barnes", + "age": 27, + "email": "kimberly.barnes@example.com" + }, + { + "$id": "d1p47hl97pw6xowb", + "name": "Stephen Miller", + "age": 53, + "email": "stephen.miller@example.com" + }, + { + "$id": "yxk6qaa5ryb3gqrb", + "name": "Yvonne Newman", + "age": 41, + "email": "yvonne.newman@example.com" + }, + { + "$id": "ifkeo7j8t7hfd7z8", + "name": "Carol Kane", + "age": 38, + "email": "carol.kane@example.com" + }, + { + "$id": "e1q4lq8vvpxp9ysb", + "name": "Doris Foster", + "age": 44, + "email": "doris.foster@example.com" + }, + { + "$id": "obec6d52dc6swzsf", + "name": "Joseph Stokes", + "age": 28, + "email": "joseph.stokes@example.com" + }, + { + "$id": "26v06la6ug8wkvim", + "name": "Steve Williams", + "age": 31, + "email": "steve.williams@example.com" + }, + { + "$id": "m4glre4ch12vkxp6", + "name": "James Carey", + "age": 29, + "email": "james.carey@example.com" + }, + { + "$id": "jee8fyfffnjugsd5", + "name": "Kathryn Henry", + "age": 38, + "email": "kathryn.henry@example.com" + }, + { + "$id": "miquc6ljb9l3a31r", + "name": "Christopher Landry", + "age": 23, + "email": "christopher.landry@example.com" + }, + { + "$id": "ghf7da7seeuj1zdl", + "name": "Jennifer Mcgee", + "age": 62, + "email": "jennifer.mcgee@example.com" + }, + { + "$id": "x7h11phjrz77w0q8", + "name": "Cathy Church", + "age": 35, + "email": "cathy.church@example.com" + }, + { + "$id": "dn8u5lsux4708z6j", + "name": "Jose Lopez", + "age": 41, + "email": "jose.lopez@example.com" + }, + { + "$id": "zb7fdlyohuyy5i9k", + "name": "William Rose", + "age": 30, + "email": "william.rose@example.com" + }, + { + "$id": "qyrj8m9krp4dt4wt", + "name": "Sarah Ford", + "age": 26, + "email": "sarah.ford@example.com" + }, + { + "$id": "t6t673zpfyhhz8pg", + "name": "Alisha Jones", + "age": 61, + "email": "alisha.jones@example.com" + }, + { + "$id": "0hfbo0iy1q9bwc2n", + "name": "Kristin Kelly", + "age": 61, + "email": "kristin.kelly@example.com" + }, + { + "$id": "8alv4e4xrpcj443z", + "name": "Brendan Stout", + "age": 40, + "email": "brendan.stout@example.com" + }, + { + "$id": "qxm7a0z32xdkzxdj", + "name": "Kelly Cruz", + "age": 18, + "email": "kelly.cruz@example.com" + }, + { + "$id": "885mti7j7oiz5p5g", + "name": "Samantha Martin", + "age": 50, + "email": "samantha.martin@example.com" + }, + { + "$id": "v8i7dvhby6711m66", + "name": "David Santos", + "age": 46, + "email": "david.santos@example.com" + }, + { + "$id": "rggc0ow8ccd2jgvp", + "name": "Elizabeth Carroll", + "age": 22, + "email": "elizabeth.carroll@example.com" + }, + { + "$id": "012472s64rvzq1c4", + "name": "Corey Owens", + "age": 46, + "email": "corey.owens@example.com" + }, + { + "$id": "0k2xrwj4g33ut14y", + "name": "Shelby Mueller", + "age": 65, + "email": "shelby.mueller@example.com" + }, + { + "$id": "s3y9rl4uzf3difiq", + "name": "Dr. Sylvia Myers", + "age": 29, + "email": "sylvia.myers@example.com" + }, + { + "$id": "ntpc2td892t7f6an", + "name": "Scott Freeman", + "age": 48, + "email": "scott.freeman@example.com" + }, + { + "$id": "7f703gibyr5ijdmt", + "name": "Christopher Atkinson", + "age": 21, + "email": "christopher.atkinson@example.com" + }, + { + "$id": "r2jdf2pivkxmqd0l", + "name": "Sean Diaz", + "age": 31, + "email": "sean.diaz@example.com" + }, + { + "$id": "fj98fji1lrxeigs9", + "name": "Bobby Dyer", + "age": 57, + "email": "bobby.dyer@example.com" + }, + { + "$id": "mehqmzp9u7xv1z3j", + "name": "Daniel Hall", + "age": 62, + "email": "daniel.hall@example.com" + }, + { + "$id": "4cd5ln65qjfv3h4j", + "name": "Jennifer Ramirez", + "age": 65, + "email": "jennifer.ramirez@example.com" + }, + { + "$id": "wdi6ap0oa7m1ab1d", + "name": "Angela Jackson", + "age": 57, + "email": "angela.jackson@example.com" + }, + { + "$id": "l2foqjhxvjhjzijb", + "name": "Kelly Lewis", + "age": 36, + "email": "kelly.lewis@example.com" + }, + { + "$id": "d963t5yu35uagwm4", + "name": "Jessica Munoz", + "age": 55, + "email": "jessica.munoz@example.com" + }, + { + "$id": "99ez9uxsim8zp64m", + "name": "Kelly George", + "age": 65, + "email": "kelly.george@example.com" + }, + { + "$id": "v7wl221gycftl63d", + "name": "Anthony Johnson", + "age": 65, + "email": "anthony.johnson@example.com" + }, + { + "$id": "p2zzj0lnmjvqzfc3", + "name": "Regina Fields", + "age": 61, + "email": "regina.fields@example.com" + }, + { + "$id": "fk655e243z2ivvx6", + "name": "Sharon Schaefer", + "age": 30, + "email": "sharon.schaefer@example.com" + }, + { + "$id": "4ywsv6fw8g2d8ncw", + "name": "Jacob French", + "age": 62, + "email": "jacob.french@example.com" + }, + { + "$id": "y61q9k6g4h0fxxz4", + "name": "Jessica Costa", + "age": 23, + "email": "jessica.costa@example.com" + }, + { + "$id": "knj4hfzsthk7vx5n", + "name": "George Hardy", + "age": 53, + "email": "george.hardy@example.com" + }, + { + "$id": "a88u9w2pct2nn8l6", + "name": "Andrea Allison", + "age": 20, + "email": "andrea.allison@example.com" + }, + { + "$id": "hw960v1ybycrwr5o", + "name": "Kevin Ferguson", + "age": 32, + "email": "kevin.ferguson@example.com" + }, + { + "$id": "j9garslpgx6jgzgb", + "name": "Joseph Johnson", + "age": 58, + "email": "joseph.johnson@example.com" + }, + { + "$id": "gv101bz36elm84cd", + "name": "Ashley Martinez", + "age": 18, + "email": "ashley.martinez@example.com" + }, + { + "$id": "xrvzgt3gc0c7g4cl", + "name": "Charles Nixon", + "age": 23, + "email": "charles.nixon@example.com" + }, + { + "$id": "awjlu7uk0eutcfpb", + "name": "Carol Dudley", + "age": 40, + "email": "carol.dudley@example.com" + }, + { + "$id": "95oi26p2zdudpime", + "name": "David Weber", + "age": 51, + "email": "david.weber@example.com" + }, + { + "$id": "h8x7pkhdvu5bcp89", + "name": "Scott Robinson", + "age": 32, + "email": "scott.robinson@example.com" + }, + { + "$id": "oj6cu4jm1z2afe7s", + "name": "Anthony Hardy", + "age": 38, + "email": "anthony.hardy@example.com" + }, + { + "$id": "hgsdi1g30poqqmf0", + "name": "Mackenzie Owens", + "age": 52, + "email": "mackenzie.owens@example.com" + }, + { + "$id": "8fzdz914bqlqk2tc", + "name": "Brian Foster", + "age": 27, + "email": "brian.foster@example.com" + }, + { + "$id": "fwlqoeiunjhczpl0", + "name": "Hannah Forbes", + "age": 56, + "email": "hannah.forbes@example.com" + }, + { + "$id": "rsv8156goe8z4j6j", + "name": "Lauren Reed", + "age": 26, + "email": "lauren.reed@example.com" + }, + { + "$id": "1fjqv3w7uwbswe2p", + "name": "Morgan Smith", + "age": 28, + "email": "morgan.smith@example.com" + }, + { + "$id": "soqrzmhhg05hhzn4", + "name": "Samantha Alexander", + "age": 65, + "email": "samantha.alexander@example.com" + }, + { + "$id": "8quy52cto9kjjokp", + "name": "Tiffany Roberts", + "age": 20, + "email": "tiffany.roberts@example.com" + }, + { + "$id": "e3i1g1lw04v7jd89", + "name": "Emily Hayes", + "age": 34, + "email": "emily.hayes@example.com" + }, + { + "$id": "s7n8lzb0sw7h93z1", + "name": "Rebecca Villegas", + "age": 52, + "email": "rebecca.villegas@example.com" + }, + { + "$id": "e2lc7i81tpkqs1rp", + "name": "Donald Shah", + "age": 61, + "email": "donald.shah@example.com" + }, + { + "$id": "3oe2mysup1xluiw0", + "name": "Denise Cain", + "age": 59, + "email": "denise.cain@example.com" + }, + { + "$id": "1vqypc37f85nuqz4", + "name": "Kristine Ramirez", + "age": 53, + "email": "kristine.ramirez@example.com" + }, + { + "$id": "m0uh7r3dc6z8ucb4", + "name": "Stacey Adkins", + "age": 61, + "email": "stacey.adkins@example.com" + }, + { + "$id": "jdofz6x1ahganmqf", + "name": "Daniel Hunt", + "age": 20, + "email": "daniel.hunt@example.com" + }, + { + "$id": "vbe903c2q4m4q97g", + "name": "Roberta Johnson", + "age": 48, + "email": "roberta.johnson@example.com" + }, + { + "$id": "sndngrxuwpd93pdb", + "name": "Jason Williamson", + "age": 39, + "email": "jason.williamson@example.com" + }, + { + "$id": "66hvaw2p5xwf07p8", + "name": "Sandra Robinson", + "age": 50, + "email": "sandra.robinson@example.com" + }, + { + "$id": "9pvingfsl8cmag5c", + "name": "Steve Rice", + "age": 25, + "email": "steve.rice@example.com" + }, + { + "$id": "qe154m5hh00u4iiz", + "name": "Kimberly Fritz", + "age": 53, + "email": "kimberly.fritz@example.com" + }, + { + "$id": "avqnbrco2f0tfupk", + "name": "Brianna Reynolds", + "age": 33, + "email": "brianna.reynolds@example.com" + }, + { + "$id": "cqs10gi2qu1r3ugb", + "name": "Alexander Williams", + "age": 50, + "email": "alexander.williams@example.com" + }, + { + "$id": "jrpmfi6hmm7pmegp", + "name": "Andrew Thomas", + "age": 25, + "email": "andrew.thomas@example.com" + }, + { + "$id": "heeab2qqf0zm446f", + "name": "Austin Williams", + "age": 57, + "email": "austin.williams@example.com" + }, + { + "$id": "bkhugvnil7kjchm6", + "name": "Nicholas Williams", + "age": 24, + "email": "nicholas.williams@example.com" + }, + { + "$id": "b045j302pvv8l1p4", + "name": "Mrs. Michelle Cisneros", + "age": 48, + "email": "michelle.cisneros@example.com" + }, + { + "$id": "aikhii5q210lrfpr", + "name": "Stacey Smith", + "age": 39, + "email": "stacey.smith@example.com" + }, + { + "$id": "x0zajitea1z2dfo0", + "name": "Laura Beck", + "age": 20, + "email": "laura.beck@example.com" + }, + { + "$id": "abeecki7mdff1tv0", + "name": "Molly Clark", + "age": 51, + "email": "molly.clark@example.com" + }, + { + "$id": "yizama8r3i1to548", + "name": "Carmen Morris", + "age": 41, + "email": "carmen.morris@example.com" + }, + { + "$id": "8690yh971g4rgspj", + "name": "Amanda Munoz", + "age": 20, + "email": "amanda.munoz@example.com" + }, + { + "$id": "cd9vk5v97t359ul2", + "name": "Rachel Collins", + "age": 44, + "email": "rachel.collins@example.com" + }, + { + "$id": "wrkgmx1v0w9ja4l8", + "name": "John Alexander", + "age": 18, + "email": "john.alexander@example.com" + }, + { + "$id": "kxp3ucqo6ped4ss7", + "name": "Stacy Hunter", + "age": 22, + "email": "stacy.hunter@example.com" + }, + { + "$id": "dbvv8okae2qgo0gm", + "name": "Eric Massey", + "age": 40, + "email": "eric.massey@example.com" + }, + { + "$id": "9tn3nm6ppnayisje", + "name": "Scott Garcia", + "age": 20, + "email": "scott.garcia@example.com" + }, + { + "$id": "1xuc5t60xpcvd4qi", + "name": "Cassandra Nelson", + "age": 35, + "email": "cassandra.nelson@example.com" + }, + { + "$id": "qao1nulwn0kqyfkc", + "name": "Aaron Johnson", + "age": 50, + "email": "aaron.johnson@example.com" + }, + { + "$id": "kd2q6owvuwsy5knx", + "name": "Shannon Sherman", + "age": 45, + "email": "shannon.sherman@example.com" + }, + { + "$id": "wsl37kjo0bib4wrc", + "name": "April Garcia", + "age": 60, + "email": "april.garcia@example.com" + }, + { + "$id": "ujlz7k84xzfx4khs", + "name": "Evan Lynn", + "age": 20, + "email": "evan.lynn@example.com" + } +] \ No newline at end of file diff --git a/tests/resources/json/missing-column.json b/tests/resources/json/missing-column.json new file mode 100644 index 0000000000..ac7a8e1a85 --- /dev/null +++ b/tests/resources/json/missing-column.json @@ -0,0 +1,402 @@ +[ + { + "$id": "hxfcwpcas5xokpwe", + "name": "Diamond Mendez" + }, + { + "$id": "gw8nxwf6esn3tfwf", + "name": "Michael Huff" + }, + { + "$id": "xb6bxg56lral1qy9", + "name": "Alyssa Rodriguez" + }, + { + "$id": "imerjq5j36y3agh2", + "name": "Barbara Smith" + }, + { + "$id": "07yq9qdlhmbzmr35", + "name": "Evelyn Edwards" + }, + { + "$id": "ksqo631sbhwj5ltg", + "name": "Tina Richardson" + }, + { + "$id": "j7zlndgu0gbshp15", + "name": "Joel Hernandez" + }, + { + "$id": "mfntvnljrcmf7h6v", + "name": "Zachary Cooper" + }, + { + "$id": "5f9b01nziqu2h8ed", + "name": "Brittany Spears" + }, + { + "$id": "4vxzbnzraqznk5u8", + "name": "Holly White" + }, + { + "$id": "d4ywy3mtphaatbpf", + "name": "Kimberly Barnes" + }, + { + "$id": "88odnk6nthyyvbal", + "name": "Stephen Miller" + }, + { + "$id": "08oekee3fn7mzaa5", + "name": "Yvonne Newman" + }, + { + "$id": "quw55kn9895i5e4v", + "name": "Carol Kane" + }, + { + "$id": "nge6bm8ykripei6f", + "name": "Doris Foster" + }, + { + "$id": "4k16i33s0xl2ypx9", + "name": "Joseph Stokes" + }, + { + "$id": "q0j5rxbgid66snyf", + "name": "Steve Williams" + }, + { + "$id": "n1oxun7mqq3p103y", + "name": "James Carey" + }, + { + "$id": "0dbvs840jkf8i0ye", + "name": "Kathryn Henry" + }, + { + "$id": "5sfaidgs1h87v15v", + "name": "Christopher Landry" + }, + { + "$id": "vg3punvfu5khmf41", + "name": "Jennifer Mcgee" + }, + { + "$id": "f933qydr9u5b2r11", + "name": "Cathy Church" + }, + { + "$id": "wjv87y1inf8yk32s", + "name": "Jose Lopez" + }, + { + "$id": "uljysdvdlcyrbrwk", + "name": "William Rose" + }, + { + "$id": "ot8xtzh77j55wq0s", + "name": "Sarah Ford" + }, + { + "$id": "9t76vnsv2u36s43t", + "name": "Alisha Jones" + }, + { + "$id": "66y4tnty62hw8c02", + "name": "Kristin Kelly" + }, + { + "$id": "2punfblazi5v16ar", + "name": "Brendan Stout" + }, + { + "$id": "sxhr4nf5w2gx4wbg", + "name": "Kelly Cruz" + }, + { + "$id": "68dvrqfwqnkq5el9", + "name": "Samantha Martin" + }, + { + "$id": "20192l6dbeinhkh0", + "name": "David Santos" + }, + { + "$id": "si0l4dgay09ebfmf", + "name": "Elizabeth Carroll" + }, + { + "$id": "lhse40vbldqb6ap1", + "name": "Corey Owens" + }, + { + "$id": "h5t3pslykyx3kxfm", + "name": "Shelby Mueller" + }, + { + "$id": "ldc0luydrw6jub0f", + "name": "Dr. Sylvia Myers" + }, + { + "$id": "voc9628xg4dsgw2y", + "name": "Scott Freeman" + }, + { + "$id": "o4y0gk3gqv1ax2fz", + "name": "Christopher Atkinson" + }, + { + "$id": "u1n3x4e4u7e0vzj6", + "name": "Sean Diaz" + }, + { + "$id": "s36eskwtm0w7lwr7", + "name": "Bobby Dyer" + }, + { + "$id": "4hjnag1p5iwvtixd", + "name": "Daniel Hall" + }, + { + "$id": "m91d80oxsa216zbh", + "name": "Jennifer Ramirez" + }, + { + "$id": "5hj6858zo2g85n6v", + "name": "Angela Jackson" + }, + { + "$id": "8m8oihv9a1e7nn92", + "name": "Kelly Lewis" + }, + { + "$id": "7azy39la0no0mxi7", + "name": "Jessica Munoz" + }, + { + "$id": "47pmjkhnnqhyit8c", + "name": "Kelly George" + }, + { + "$id": "6j6cpy4kgneg1mmh", + "name": "Anthony Johnson" + }, + { + "$id": "tnlmtvap1zz89km9", + "name": "Regina Fields" + }, + { + "$id": "6cyuvnwwqdmrpfzh", + "name": "Sharon Schaefer" + }, + { + "$id": "p1v4pyu2pqodc0ey", + "name": "Jacob French" + }, + { + "$id": "6npynnhjt2jd05xo", + "name": "Jessica Costa" + }, + { + "$id": "wcxedf13n2e9qi4l", + "name": "George Hardy" + }, + { + "$id": "yf2xlcmszk2tqeig", + "name": "Andrea Allison" + }, + { + "$id": "3bf2zzv7poststwa", + "name": "Kevin Ferguson" + }, + { + "$id": "c2iataz0hhv39q63", + "name": "Joseph Johnson" + }, + { + "$id": "3e8npxhov4a39pvq", + "name": "Ashley Martinez" + }, + { + "$id": "t7dp41tysipytywq", + "name": "Charles Nixon" + }, + { + "$id": "z8cztq7c47phyfhk", + "name": "Carol Dudley" + }, + { + "$id": "2636f9d8r4ipm3h6", + "name": "David Weber" + }, + { + "$id": "eh3f6wxtvkjq6ykq", + "name": "Scott Robinson" + }, + { + "$id": "raskbwpsje69a59h", + "name": "Anthony Hardy" + }, + { + "$id": "90hn1p0b4cs9e2og", + "name": "Mackenzie Owens" + }, + { + "$id": "am3swwfbo076x0v1", + "name": "Brian Foster" + }, + { + "$id": "5uw7utb9lq5cfncw", + "name": "Hannah Forbes" + }, + { + "$id": "cs6mbfzkzifefx6r", + "name": "Lauren Reed" + }, + { + "$id": "ftw3uvztziiz9x00", + "name": "Morgan Smith" + }, + { + "$id": "uhrqseeo43mozpaq", + "name": "Samantha Alexander" + }, + { + "$id": "pvvmzyfc1lxor11e", + "name": "Tiffany Roberts" + }, + { + "$id": "jia7bdag4abz123s", + "name": "Emily Hayes" + }, + { + "$id": "h6oozcngbz8o5x4y", + "name": "Rebecca Villegas" + }, + { + "$id": "9v6z1pn2f9twcy12", + "name": "Donald Shah" + }, + { + "$id": "wzz3jduioso77o7f", + "name": "Denise Cain" + }, + { + "$id": "u51plhgvjodkswnr", + "name": "Kristine Ramirez" + }, + { + "$id": "t1uhkmiytfyc13vc", + "name": "Stacey Adkins" + }, + { + "$id": "iqaqnf0ybg2ct507", + "name": "Daniel Hunt" + }, + { + "$id": "idwrwv2uu4hcpv2i", + "name": "Roberta Johnson" + }, + { + "$id": "2yd2hd6auetjacyo", + "name": "Jason Williamson" + }, + { + "$id": "egrmdbibnjhi914x", + "name": "Sandra Robinson" + }, + { + "$id": "15m1pz2bb0ercgyk", + "name": "Steve Rice" + }, + { + "$id": "0i21bhkxdagjurb7", + "name": "Kimberly Fritz" + }, + { + "$id": "726ofi7h5snreq67", + "name": "Brianna Reynolds" + }, + { + "$id": "csqxse3wym56eim6", + "name": "Alexander Williams" + }, + { + "$id": "qeaoylnrsf8p3byg", + "name": "Andrew Thomas" + }, + { + "$id": "edsswobumzyzbvhf", + "name": "Austin Williams" + }, + { + "$id": "hdzhzpt0ahy5hkib", + "name": "Nicholas Williams" + }, + { + "$id": "w1qmvmg4roa8xnwu", + "name": "Mrs. Michelle Cisneros" + }, + { + "$id": "3z3o73x7adyuo6w0", + "name": "Stacey Smith" + }, + { + "$id": "sse2u5zlgoqrgmcf", + "name": "Laura Beck" + }, + { + "$id": "rvovijmvch58r4yx", + "name": "Molly Clark" + }, + { + "$id": "doe06nrx8sg5mcuv", + "name": "Carmen Morris" + }, + { + "$id": "jbjdwuvj5s4kw04y", + "name": "Amanda Munoz" + }, + { + "$id": "6k2ewkla7js0yw23", + "name": "Rachel Collins" + }, + { + "$id": "fcxuyr4kkhrnigu1", + "name": "John Alexander" + }, + { + "$id": "d25fuwlos5mk07o0", + "name": "Stacy Hunter" + }, + { + "$id": "1vdai2rxmwd57oet", + "name": "Eric Massey" + }, + { + "$id": "pq4jnt9izu1wlrzd", + "name": "Scott Garcia" + }, + { + "$id": "lz9kfc0lty5xcz14", + "name": "Cassandra Nelson" + }, + { + "$id": "pu7w6tyab5jd4we9", + "name": "Aaron Johnson" + }, + { + "$id": "8dupswd2kqwdyn8v", + "name": "Shannon Sherman" + }, + { + "$id": "ye466l71jthiz2p6", + "name": "April Garcia" + }, + { + "$id": "xogsmfwb73l16qdt", + "name": "Evan Lynn" + } +] \ No newline at end of file diff --git a/tests/resources/json/vectorsdb-documents.json b/tests/resources/json/vectorsdb-documents.json new file mode 100644 index 0000000000..f1bada8a7e --- /dev/null +++ b/tests/resources/json/vectorsdb-documents.json @@ -0,0 +1,262 @@ +[ + { + "$id": "vec01", + "metadata": { + "title": "Vector Document 1", + "score": 0.643, + "category": "art" + }, + "embeddings": [ + -0.516361, + 0.261456, + -0.77323, + 0.909775, + -0.003065, + -0.626888, + 0.704171, + -0.366385, + 0.198681, + 0.854914, + -0.227548, + -0.14278, + -0.548405, + -0.822939, + 0.407267, + 0.570791 + ] + }, + { + "$id": "vec02", + "metadata": { + "title": "Vector Document 2", + "score": 0.322, + "category": "art" + }, + "embeddings": [ + 0.103211, + 0.028378, + 0.806001, + -0.412638, + -0.200009, + 0.448239, + -0.781144, + 0.621957, + -0.868352, + 0.217013, + 0.308217, + -0.851528, + -0.340766, + 0.805996, + -0.065638, + -0.363951 + ] + }, + { + "$id": "vec03", + "metadata": { + "title": "Vector Document 3", + "score": 0.503, + "category": "science" + }, + "embeddings": [ + 0.971901, + -0.475154, + 0.60526, + -0.151618, + -0.938199, + -0.139977, + 0.154581, + -0.868021, + 0.069567, + 0.233545, + -0.164826, + 0.239036, + 0.8875, + 0.488075, + 0.787231, + 0.090293 + ] + }, + { + "$id": "vec04", + "metadata": { + "title": "Vector Document 4", + "score": 0.943, + "category": "science" + }, + "embeddings": [ + 0.372035, + -0.868405, + -0.974987, + -0.836673, + 0.030057, + -0.667698, + 0.095189, + 0.518373, + -0.173732, + 0.966379, + 0.509861, + -0.172607, + 0.420855, + -0.534562, + 0.600872, + 0.194391 + ] + }, + { + "$id": "vec05", + "metadata": { + "title": "Vector Document 5", + "score": 0.924, + "category": "music" + }, + "embeddings": [ + -0.318783, + -0.876486, + 0.843971, + -0.472869, + -0.350164, + 0.936237, + -0.741591, + 0.298213, + -0.075429, + 0.243415, + 0.929625, + 0.96649, + 0.251843, + -0.371953, + 0.348079, + 0.090382 + ] + }, + { + "$id": "vec06", + "metadata": { + "title": "Vector Document 6", + "score": 0.385, + "category": "science" + }, + "embeddings": [ + -0.608111, + 0.549885, + 0.720346, + 0.260442, + -0.023344, + 0.800346, + -0.956586, + -0.407161, + 0.516883, + 0.230456, + 0.376704, + -0.347203, + 0.925657, + -0.486608, + 0.330032, + 0.323414 + ] + }, + { + "$id": "vec07", + "metadata": { + "title": "Vector Document 7", + "score": 0.61, + "category": "science" + }, + "embeddings": [ + 0.45451, + -0.251783, + -0.479459, + 0.167412, + 0.890343, + 0.415136, + -0.506263, + 0.318171, + -0.729294, + -0.907919, + 0.607033, + 0.652333, + 0.97129, + -0.118671, + 0.110045, + -0.514242 + ] + }, + { + "$id": "vec08", + "metadata": { + "title": "Vector Document 8", + "score": 0.107, + "category": "history" + }, + "embeddings": [ + -0.883255, + -0.294593, + -0.153187, + 0.595936, + 0.341818, + -0.410719, + 0.017348, + -0.350047, + -0.888906, + 0.344646, + 0.184335, + -0.429053, + 0.540542, + -0.408493, + -0.982748, + -0.255031 + ] + }, + { + "$id": "vec09", + "metadata": { + "title": "Vector Document 9", + "score": 0.914, + "category": "art" + }, + "embeddings": [ + 0.31891, + 0.502493, + -0.245712, + -0.325282, + -0.514772, + 0.097323, + 0.812412, + -0.762069, + 0.846688, + -0.391482, + 0.879953, + 0.986352, + -0.562588, + -0.566111, + 0.163109, + 0.119346 + ] + }, + { + "$id": "vec10", + "metadata": { + "title": "Vector Document 10", + "score": 0.169, + "category": "science" + }, + "embeddings": [ + -0.563645, + -0.19119, + -0.853168, + -0.526952, + -0.459635, + 0.850766, + -0.345261, + 0.547467, + 0.455409, + -0.507607, + -0.228436, + -0.587395, + 0.109146, + 0.190046, + 0.861559, + -0.724741 + ] + } +] \ No newline at end of file diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index fc1779efad..58fe3113e1 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -25,7 +25,7 @@ class KeyTest extends TestCase $roleScopes = Config::getParam('roles', [])[User::ROLE_APPS]['scopes']; $guestRoleScopes = Config::getParam('roles', [])[User::ROLE_GUESTS]['scopes']; - $key = static::generateKey($projectId, $usage, $scopes); + $key = self::generateKey($projectId, $usage, $scopes); $decoded = Key::decode( project: new Document(['$id' => $projectId]), team: new Document(), @@ -50,7 +50,7 @@ class KeyTest extends TestCase 'previewAuthDisabled' => true, 'deploymentStatusIgnored' => true, ]; - $key = static::generateKey($projectId, $usage, $scopes, extra: $extra); + $key = self::generateKey($projectId, $usage, $scopes, extra: $extra); $decoded = Key::decode( project: new Document(['$id' => $projectId]), team: new Document(), @@ -88,7 +88,7 @@ class KeyTest extends TestCase $this->assertEquals('UNKNOWN', $decoded->getName()); // Decode expired dynamic key - $expiredKey = static::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60); + $expiredKey = self::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60); \sleep(2); $decoded = Key::decode( project: new Document(['$id' => $projectId]), diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index d5936e4b8f..d050ce5f64 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -5,7 +5,6 @@ namespace Tests\Unit\Event; use Appwrite\Event\Event; use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use Utopia\Queue\Publisher; require_once __DIR__ . '/../../../app/init.php'; @@ -13,7 +12,7 @@ class EventTest extends TestCase { protected ?Event $object = null; protected string $queue = ''; - protected Publisher $publisher; + protected MockPublisher $publisher; public function setUp(): void { diff --git a/tests/unit/Event/MockPublisher.php b/tests/unit/Event/MockPublisher.php index a7118d3c09..8363ed4e85 100644 --- a/tests/unit/Event/MockPublisher.php +++ b/tests/unit/Event/MockPublisher.php @@ -9,7 +9,7 @@ class MockPublisher implements Publisher { private array $events = []; - public function enqueue(Queue $queue, array $payload): bool + public function enqueue(Queue $queue, array $payload, bool $priority = false): bool { if (!isset($this->events[$queue->name])) { $this->events[$queue->name] = []; diff --git a/tests/unit/Functions/Validator/HeadersTest.php b/tests/unit/Functions/Validator/HeadersTest.php index 4a45f57427..563131d480 100644 --- a/tests/unit/Functions/Validator/HeadersTest.php +++ b/tests/unit/Functions/Validator/HeadersTest.php @@ -76,11 +76,6 @@ class HeadersTest extends TestCase ]; $this->assertFalse($this->object->isValid($headers)); - $headers = [ - null => 'value', - ]; - $this->assertFalse($this->object->isValid($headers)); - $headers = [ 'X-Header' => null, ]; diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 0b7e7effcb..507a4e25f6 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -134,7 +134,7 @@ class ModuleTest extends TestCase $this->assertActionParams($action, [ 'appDomain', 'httpPort', 'httpsPort', 'emailCertificates', 'opensslKey', 'assistantOpenAIKey', 'accountEmail', 'accountPassword', 'database', - 'installId', 'retryStep', + 'installId', 'retryStep', 'migrate', ]); $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); } diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php index 4094b43246..b3638e7d3a 100644 --- a/tests/unit/Utopia/Database/Documents/UserTest.php +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -171,36 +171,40 @@ class UserTest extends TestCase public function testIsPrivilegedUser(): void { - $this->assertEquals(false, User::isPrivileged([])); - $this->assertEquals(false, User::isPrivileged([Role::guests()->toString()])); - $this->assertEquals(false, User::isPrivileged([Role::users()->toString()])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_ADMIN])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_DEVELOPER])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER])); - $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS])); - $this->assertEquals(false, User::isPrivileged([User::ROLE_SYSTEM])); + $user = new User(); - $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); - $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isPrivileged([])); + $this->assertEquals(false, $user->isPrivileged([Role::guests()->toString()])); + $this->assertEquals(false, $user->isPrivileged([Role::users()->toString()])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_ADMIN])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_DEVELOPER])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_SYSTEM])); + + $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); } public function testIsAppUser(): void { - $this->assertEquals(false, User::isApp([])); - $this->assertEquals(false, User::isApp([Role::guests()->toString()])); - $this->assertEquals(false, User::isApp([Role::users()->toString()])); - $this->assertEquals(false, User::isApp([User::ROLE_ADMIN])); - $this->assertEquals(false, User::isApp([User::ROLE_DEVELOPER])); - $this->assertEquals(false, User::isApp([User::ROLE_OWNER])); - $this->assertEquals(true, User::isApp([User::ROLE_APPS])); - $this->assertEquals(false, User::isApp([User::ROLE_SYSTEM])); + $user = new User(); - $this->assertEquals(true, User::isApp([User::ROLE_APPS, User::ROLE_APPS])); - $this->assertEquals(true, User::isApp([User::ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(false, User::isApp([User::ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(false, User::isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isApp([])); + $this->assertEquals(false, $user->isApp([Role::guests()->toString()])); + $this->assertEquals(false, $user->isApp([Role::users()->toString()])); + $this->assertEquals(false, $user->isApp([User::ROLE_ADMIN])); + $this->assertEquals(false, $user->isApp([User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isApp([User::ROLE_OWNER])); + $this->assertEquals(true, $user->isApp([User::ROLE_APPS])); + $this->assertEquals(false, $user->isApp([User::ROLE_SYSTEM])); + + $this->assertEquals(true, $user->isApp([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(true, $user->isApp([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(false, $user->isApp([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(false, $user->isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); } public function testGuestRoles(): void diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 78a3717c38..d5cd5d800a 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -147,6 +147,21 @@ class RequestTest extends TestCase $this->assertSame('unexpected', $params['extra']); } + public function testRouteIsScopedToRequestInstance(): void + { + $firstRequest = new Request(new SwooleRequest()); + $secondRequest = new Request(new SwooleRequest()); + + $firstRoute = new Route(Request::METHOD_GET, '/first'); + $secondRoute = new Route(Request::METHOD_GET, '/second'); + + $firstRequest->setRoute($firstRoute); + $secondRequest->setRoute($secondRoute); + + $this->assertSame($firstRoute, $firstRequest->getRoute()); + $this->assertSame($secondRoute, $secondRequest->getRoute()); + } + /** * Helper to attach a route with multiple SDK methods to the request. */ diff --git a/tests/unit/Utopia/Response/Filters/V16Test.php b/tests/unit/Utopia/Response/Filters/V16Test.php index e771146e3a..2ba60d35e9 100644 --- a/tests/unit/Utopia/Response/Filters/V16Test.php +++ b/tests/unit/Utopia/Response/Filters/V16Test.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V16; use Cron\CronExpression; use PHPUnit\Framework\Attributes\DataProvider; @@ -11,10 +12,7 @@ use Utopia\Database\DateTime; class V16Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/Response/Filters/V17Test.php b/tests/unit/Utopia/Response/Filters/V17Test.php index 21d91e1314..0bdc4de53e 100644 --- a/tests/unit/Utopia/Response/Filters/V17Test.php +++ b/tests/unit/Utopia/Response/Filters/V17Test.php @@ -3,16 +3,14 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V17; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class V17Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/Response/Filters/V18Test.php b/tests/unit/Utopia/Response/Filters/V18Test.php index da169a7d0e..2e09b34515 100644 --- a/tests/unit/Utopia/Response/Filters/V18Test.php +++ b/tests/unit/Utopia/Response/Filters/V18Test.php @@ -3,16 +3,14 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V18; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class V18Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/Response/Filters/V19Test.php b/tests/unit/Utopia/Response/Filters/V19Test.php index eaeefba2fc..a53dbfe355 100644 --- a/tests/unit/Utopia/Response/Filters/V19Test.php +++ b/tests/unit/Utopia/Response/Filters/V19Test.php @@ -3,16 +3,14 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V19; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class V19Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index 452119fafb..be8cfdc216 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Utopia; use Appwrite\Utopia\Response; use Exception; use PHPUnit\Framework\TestCase; +use ReflectionProperty; use Swoole\Http\Response as SwooleResponse; use Tests\Unit\Utopia\Response\Filters\First; use Tests\Unit\Utopia\Response\Filters\Second; @@ -176,4 +177,26 @@ class ResponseTest extends TestCase $this->assertArrayHasKey('required', $single); $this->assertArrayNotHasKey('hidden', $singleFromArray); } + + public function testShowSensitiveRestoresPreviousState(): void + { + $isShowingSensitive = new ReflectionProperty(Response::class, 'showSensitive'); + + $this->assertFalse($isShowingSensitive->getValue($this->response)); + + $payload = $this->response->showSensitive(function () use ($isShowingSensitive) { + return [ + 'outer' => $isShowingSensitive->getValue($this->response), + 'inner' => $this->response->showSensitive(fn () => [ + 'state' => $isShowingSensitive->getValue($this->response), + ]), + 'afterInner' => $isShowingSensitive->getValue($this->response), + ]; + }); + + $this->assertTrue($payload['outer']); + $this->assertTrue($payload['inner']['state']); + $this->assertTrue($payload['afterInner']); + $this->assertFalse($isShowingSensitive->getValue($this->response)); + } }