Compare commits

..
Author SHA1 Message Date
Chirag Aggarwal b43f77d353 chore: update diff check 2026-03-02 22:29:56 +05:30
Jake BarnbyandGitHub 5dadb9a998 Merge pull request #11430 from appwrite/realtime-tablesdb-prefix 2026-03-02 23:52:02 +13:00
HemachandarandGitHub 164c372b21 Per-project permissions for installations (#11423)
* Per-project permissions for installations

* lint
2026-03-02 16:10:58 +05:30
HemachandarandGitHub cb260fa3d8 Add authorized field to VCS repository model (#11420)
* Add authorized field to VCS repository model

* use new API
2026-03-02 15:48:08 +05:30
ArnabChatterjee20k 05a2f56945 updated tests 2026-03-02 14:27:54 +05:30
ArnabChatterjee20k 82a5d138e0 refactor: enhance event handling by ensuring unique events and improving channel merging in Realtime adapter 2026-03-02 14:22:44 +05:30
ArnabChatterjee20k 3d4f37c482 refactor: improve channel handling for Realtime events and update tests for TablesDB compatibility 2026-03-02 14:12:13 +05:30
Jake BarnbyandGitHub b03ad6d523 Merge pull request #11392 from appwrite/migration-resource-enum-fix 2026-03-02 20:09:05 +13:00
Prem Palanisamy 5c2b1be581 chore: update sdk-generator to 1.11.3 2026-02-27 09:04:18 +00:00
Prem Palanisamy b3b2fe3ba2 Merge remote-tracking branch 'origin/1.8.x' into migration-resource-enum-fix 2026-02-27 09:01:31 +00:00
Jake BarnbyandGitHub 9667ec2434 Merge pull request #11414 from appwrite/fix-realtime-regions 2026-02-27 08:10:01 +00:00
Jake BarnbyandGitHub 3c4c78d086 Merge branch '1.8.x' into fix-realtime-regions 2026-02-27 06:07:06 +00:00
Jake BarnbyandGitHub 98aab7b34d Merge pull request #11421 from appwrite/realtime-log-improve 2026-02-27 05:55:13 +00:00
Prem Palanisamy 81e828a200 use named arguments and elvis operator for realtime error logging 2026-02-26 15:45:46 +00:00
Prem Palanisamy 218d25ba1b improve realtime error logging with project, user, and auth context 2026-02-26 15:45:46 +00:00
Eldad A. FuxandGitHub 908a74f69d Merge pull request #11413 from appwrite/fix-users-optional-name-error
Fix users optional name error
2026-02-26 16:23:03 +01:00
Jake BarnbyandGitHub 8368a28ff5 Merge pull request #10832 from appwrite/cached-documents-list 2026-02-26 12:55:05 +00:00
Jake BarnbyandGitHub abe21d51cb Merge pull request #11418 from appwrite/copilot/sub-pr-11414-again 2026-02-26 12:54:03 +00:00
copilot-swe-agent[bot]andabnegate 339ecc418d test: remove testConnectionFailureRegionMismatch and mock PATCH route
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 12:46:47 +00:00
copilot-swe-agent[bot]andabnegate 5ff7e2af24 fix: revert regions.php, add mock PATCH route for project region in tests
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 12:31:05 +00:00
copilot-swe-agent[bot]andabnegate 9aa47c267d test: add region mismatch failure test and fra region to config
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 12:14:23 +00:00
Prem Palanisamy 12dca64b60 Merge remote-tracking branch 'origin/1.8.x' into migration-resource-enum-fix 2026-02-26 12:04:16 +00:00
copilot-swe-agent[bot]andabnegate 952f48c72a fix: use AppwriteException, fix console region, add region check test
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 11:55:15 +00:00
copilot-swe-agent[bot] 4e80f7eb9a Initial plan 2026-02-26 11:35:32 +00:00
Jake BarnbyandGitHub 9db037722c Merge pull request #11416 from appwrite/copilot/sub-pr-11414 2026-02-26 11:33:44 +00:00
eldadfux ded05ebea2 Merge remote-tracking branch 'origin/1.8.x' into fix-users-optional-name-error 2026-02-26 12:09:12 +01:00
Eldad A. FuxandGitHub a71f3555ae Merge pull request #11412 from appwrite/fix-better-error-for-functions
Fix better error for functions
2026-02-26 12:07:31 +01:00
copilot-swe-agent[bot]andabnegate 4500349fda fix: use 'default' fallback for _APP_REGION in realtime.php
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 11:00:09 +00:00
copilot-swe-agent[bot] 611495ec28 Initial plan 2026-02-26 10:50:52 +00:00
Jake Barnby 347436d370 Add region check 2026-02-26 23:37:26 +13:00
Jake BarnbyandGitHub dd925e335b Merge pull request #11411 from appwrite/bump-pools 2026-02-26 10:35:46 +00:00
Chirag AggarwalandGitHub 0e8b5f1d04 Merge pull request #11400 from appwrite/fix-execution-timeout-status
fix: show timed-out executions as failed across API endpoints
2026-02-26 15:43:00 +05:30
Jake BarnbyandClaude Opus 4.6 8dfaf4a9a9 fix: remove unused collectionUpdatedAt from cache key
The schema hash already captures all collection changes, making the
$updatedAt field redundant (and unreliable since Utopia preserves it).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:12:38 +13:00
Jake BarnbyandClaude Opus 4.6 d5b2f97252 fix: use md5 hash of attributes/indexes instead of counts in cache key
Counts alone miss delete/re-add scenarios where the count stays the same
but the schema is different. MD5 of the full attribute and index arrays
captures any schema change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:07:24 +13:00
eldadfux 47a81d165e Fix for user name to accept null and cast as string 2026-02-26 10:30:41 +01:00
eldadfux d7c8b9d43a Better error message when a function fail instead of general_unknown 2026-02-26 10:24:46 +01:00
ArnabChatterjee20k 7562946434 bump pools 2026-02-26 14:29:31 +05:30
Jake BarnbyandClaude Opus 4.6 4d19a5e855 fix: include attribute and index counts in document list cache key
The collection $updatedAt is preserved by Utopia Database's updateDocument()
when the value is already set, so it doesn't change when attributes/indexes
are created. Include attribute and index counts in the cache key to ensure
schema changes properly invalidate the document list cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:10:57 +13:00
Jake BarnbyandClaude Opus 4.6 1c85789a6e fix: scope cache tests to setup document IDs to avoid count mismatch
The testListDocumentsWithCache and testListDocumentsCacheBustedByAttributeChange
tests were failing because testCreateDocument (which runs earlier) adds 3 more
documents to the same movies collection, resulting in 6 docs instead of expected 3.
Additionally, the cache bust test was getting a cache 'hit' instead of 'miss' on
its first request due to sharing the same cache key as the previous test.

Fix by filtering all cache test queries to the 3 known setup document IDs, and
using distinct select/order queries in each test to avoid cache key collisions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:34:46 +13:00
HemachandarandGitHub a76a42d2dc Change validation order in delete memberships API (#11410) 2026-02-26 12:25:28 +05:30
eldadfux 79d219bf50 fix cache duplication 2026-02-26 07:50:51 +01:00
eldadfux 9b2143a2a5 Fixed cache duplication 2026-02-26 07:44:35 +01:00
Jake BarnbyandClaude Opus 4.6 b174a50b2a fix: address coderabbit review comments
- Fix indentation alignment in cache hit detection condition
- Add total count assertions for cached and non-cached responses in test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:00:25 +13:00
HemachandarandGitHub 76965252d8 Move teams API to Modules (#11358)
* Move teams API to Modules

* lint

* Move team prefs & logs API to Modules (#11359)

* Move team prefs & logs API to Modules

* format

* missin desc

* Move team memberships API to Modules (#11362)

* Move team memberships API to Modules

* fix config dir

* Cloud parity

* params

* Cloud conflicts

* refactor

* prop

* refactor

* set teamId

* feedback

* feedback 2

* fix url-encoding
2026-02-26 11:18:29 +05:30
Jake Barnby 3e0ee30428 Add tests for collection bust 2026-02-26 18:31:52 +13:00
Jake Barnby 0e7c50ef71 Include collection update time in key 2026-02-26 18:26:30 +13:00
Jake Barnby b8ee9fbe2c Update lock 2026-02-26 17:11:51 +13:00
Jake Barnby d49dea9790 Merge remote-tracking branch 'origin/1.8.x' into cached-documents-list
# Conflicts:
#	composer.lock
#	tests/e2e/Services/Databases/DatabasesBase.php
2026-02-26 17:02:33 +13:00
Chirag Aggarwal 8891890601 fix: show timed-out executions as failed across API endpoints
Executions that time out can remain stuck in waiting or processing status
in the database. This mirrors the frontend workaround from console#2788
across the relevant API endpoints for both functions and sites.

Changes:
- GET execution/log: override status to failed in response if elapsed time
  since creation exceeds the resource timeout
- LIST executions/logs: same in-response override; when caller filters by
  failed, expands DB query with OR to also fetch waiting/processing entries
  created before the timeout threshold so they appear in results; skips
  in-response override when caller explicitly requests a non-failed status
  to avoid contradicting the filter
- DELETE execution: allows deletion of timed-out executions that are still
  stored as waiting/processing by treating them as failed for the status guard

All changes are in-memory only — the database records are not modified.
Includes a note to remove once a proper DB-level fix is applied.
2026-02-26 09:22:33 +05:30
Jake BarnbyandGitHub 0272a7f337 Merge pull request #11404 from appwrite/realtime-channels-tablesdb-fix 2026-02-26 03:43:50 +00:00
Jake BarnbyandGitHub 52d5ae50d9 Merge pull request #11405 from appwrite/patch-text-types-return 2026-02-26 03:33:52 +00:00
premtsd-codeandGitHub db85004b12 Merge branch '1.8.x' into migration-resource-enum-fix 2026-02-25 23:16:52 +00:00
eldadfux b7e4f78c56 fix 2026-02-26 00:00:10 +01:00
eldadfux 159da8ba31 Fix 500 errors where we don't report duplication properly 2026-02-25 23:58:22 +01:00
HemachandarandGitHub cfddef3706 Upgrade utopia-php/vcs (#11401)
* Upgrade `utopia-php/vcs`

* fix

* bump
2026-02-25 19:54:04 +05:30
ArnabChatterjee20k ca8d613614 updated test 2026-02-25 19:46:09 +05:30
ArnabChatterjee20k 8c579cf88d updated string filtering in the filters 2026-02-25 19:29:03 +05:30
ArnabChatterjee20k a57d970840 Add support for tablesdb in event generation and realtime channels 2026-02-25 18:58:33 +05:30
Prem Palanisamy 88ce622e4a chore: update utopia-php/migration to 1.6.2 2026-02-25 12:06:12 +00:00
premtsd-codeandGitHub c041e57b8f Merge branch '1.8.x' into migration-resource-enum-fix 2026-02-25 11:57:48 +00:00
Atharva DeosthaleandGitHub 5d6e43e01b Merge pull request #11371 from appwrite/add-cursor-plugin 2026-02-25 17:11:20 +05:30
Prem Palanisamy 4a0f00f7db chore: update utopia-migration to 134efdc 2026-02-25 11:35:07 +00:00
premtsd-codeandGitHub de21cab22a Merge branch '1.8.x' into migration-resource-enum-fix 2026-02-25 10:28:28 +00:00
Prem Palanisamy f8929e1f2e fix: use utopia-migration dev branch with deployment download fixes 2026-02-25 10:25:05 +00:00
Prem Palanisamy 754ddbef3c fix: remove deployment from MigrationReport model 2026-02-25 06:47:08 +00:00
Prem Palanisamy ed93b77faa fix: remove strict WhiteList for Appwrite migration endpoints 2026-02-25 06:44:42 +00:00
Prem Palanisamy 5a6cfdbe4c fix: revert composer to 1.8.x and remove child resource counts from MigrationReport 2026-02-25 06:42:46 +00:00
Prem Palanisamy 6b90d00cc2 fix: update utopia-migration lock to include strtolower removal 2026-02-25 00:24:16 +00:00
Prem Palanisamy 3ba5e12f89 fix: add deployment and environmentVariable to MigrationReport model 2026-02-24 23:19:21 +00:00
Prem Palanisamy dc441c3973 fix: enable strict mode for Appwrite migration resource WhiteList 2026-02-24 22:44:57 +00:00
Prem Palanisamy 9745df4d37 fix: update utopia-migration to camelCase resource types 2026-02-24 22:30:32 +00:00
Prem Palanisamy 715adf62ca fix: update utopia-migration to snake_case resource types 2026-02-24 15:54:42 +00:00
Prem Palanisamy 90ab70dfc6 fix: use dev branch of utopia-php/migration for site camelCase fix 2026-02-24 15:54:42 +00:00
Prem Palanisamy b0c5185ebd feat: add site migration 2026-02-24 15:54:42 +00:00
Prem Palanisamy d58873ef14 added site migration E2E test 2026-02-24 15:54:42 +00:00
Prem Palanisamy 105c885c3f added site migration 2026-02-24 15:54:42 +00:00
Prem Palanisamy ad625029e8 feat: add migration resource enums to SDK specification 2026-02-24 15:54:42 +00:00
Jake Barnby aeaa184aec Use auth roles 2026-02-24 22:12:20 +13:00
Jake BarnbyandGitHub 9dd707cd1b Merge branch '1.8.x' into cached-documents-list 2026-02-24 09:04:32 +00:00
Jake BarnbyandGitHub 9f0ca2bc53 Update src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php 2026-01-20 20:42:36 +13:00
Jake BarnbyandGitHub 605cf0c985 Update src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php 2026-01-20 18:21:14 +13:00
shimon 7e1838dda6 typo 2026-01-19 14:02:14 +02:00
shimon 2016e9bc2f Update XList parameters: remove 'cache' and adjust 'ttl' default value to 0, reflecting changes in caching logic across tests. 2026-01-19 13:14:26 +02:00
shimon b3d1410ca9 Refactor XList parameters: rename 'total' to 'includeTotal' for clarity and update related logic in action method to maintain functionality. 2026-01-18 14:27:57 +02:00
shimon a2d56a47cd merge with 1.8x 2026-01-18 11:13:45 +02:00
shimon 316bbfe43d Merge branch '1.8.x', commit '3b5351afb6473e559c945846f0bf7e928b636d83' of github.com:appwrite/appwrite into cached-documents-list
# Conflicts:
#	composer.lock
#	src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
2026-01-18 11:13:08 +02:00
Shimon NewmanandGitHub 3b5351afb6 Merge branch '1.8.x' into cached-documents-list 2026-01-06 13:03:18 +02:00
shimon ecc4052bdc Clarify TTL parameter description in XList class for cached responses to enhance documentation accuracy. 2026-01-06 12:58:58 +02:00
shimon fc84012977 Inject user dependency into XList class for enhanced functionality and improved caching accuracy. 2026-01-06 12:56:18 +02:00
shimon c882afbd2c Enhance XList class by injecting user dependency and updating cache key format to include user ID for improved caching accuracy. 2026-01-06 12:52:36 +02:00
shimon 305697a5d3 Update composer dependencies and refactor cache handling in XList class for improved performance 2025-12-09 11:29:09 +02:00
shimon d954681132 matcing documents xlist params order 2025-12-08 15:41:13 +02:00
shimon 872345366a linter 2025-12-08 13:43:05 +02:00
shimon 5b1aec0f0b Enhance XList functionality by adding cache and TTL parameters for improved query performance and response management. 2025-12-08 13:34:09 +02:00
shimon 98d063132c composer.lock 2025-12-08 09:57:49 +02:00
shimon fcb3699e99 Merge branch 'refactor-auth-single-instance' of github.com:appwrite/appwrite into cached-documents-list
# Conflicts:
#	composer.json
#	composer.lock
2025-12-08 09:56:48 +02:00
shimon 39658a7ee1 fixing comments 2025-12-08 09:55:15 +02:00
shimon 6213df9fb3 Update composer.lock for sdk-generator version 1.5.8 and adjust caching TTL in XList to 30 seconds; modify related tests for consistency in cache behavior. 2025-11-23 12:46:16 +02:00
shimon 30df7a43d5 Refactor caching logic in XList to use JSON encoding for query serialization and update cache header keys to lowercase in tests 2025-11-18 20:11:02 +02:00
shimon 8fc9fdb7dc cached documents list 2025-11-18 16:37:52 +02:00
shimon 77905e241f cached documents list 2025-11-18 16:26:56 +02:00
57 changed files with 3608 additions and 1690 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ $console = [
'hostname' => 'localhost',
], // Current host is added on app init
],
'region' => 'fra',
'region' => System::getEnv('_APP_REGION', 'default'),
'legalName' => '',
'legalCountry' => '',
'legalState' => '',
+10
View File
@@ -630,6 +630,11 @@ return [
'description' => 'Site with the requested ID could not be found.',
'code' => 404,
],
Exception::SITE_ALREADY_EXISTS => [
'name' => Exception::SITE_ALREADY_EXISTS,
'description' => 'Site with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::SITE_TEMPLATE_NOT_FOUND => [
'name' => Exception::SITE_TEMPLATE_NOT_FOUND,
'description' => 'Site Template with the requested ID could not be found.',
@@ -1291,6 +1296,11 @@ return [
'description' => 'Message with the requested ID could not be found.',
'code' => 404,
],
Exception::MESSAGE_ALREADY_EXISTS => [
'name' => Exception::MESSAGE_ALREADY_EXISTS,
'description' => 'Message with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::MESSAGE_MISSING_TARGET => [
'name' => Exception::MESSAGE_MISSING_TARGET,
'description' => 'Message with the requested ID has no recipients (topics or users or targets).',
+1 -1
View File
@@ -160,7 +160,7 @@ return [
'name' => 'Teams',
'subtitle' => 'The Teams service allows you to group users of your project and to enable them to share read and write access to your project resources',
'description' => '/docs/services/teams.md',
'controller' => 'api/teams.php',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/client/teams',
+21 -6
View File
@@ -3251,7 +3251,7 @@ Http::post('/v1/messaging/messages/email')
}
}
$message = $dbForProject->createDocument('messages', new Document([
$message = new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_EMAIL,
'topics' => $topics,
@@ -3267,7 +3267,12 @@ Http::post('/v1/messaging/messages/email')
'attachments' => $attachments,
],
'status' => $status,
]));
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3400,7 +3405,7 @@ Http::post('/v1/messaging/messages/sms')
}
}
$message = $dbForProject->createDocument('messages', new Document([
$message = new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_SMS,
'topics' => $topics,
@@ -3410,7 +3415,12 @@ Http::post('/v1/messaging/messages/sms')
'content' => $content,
],
'status' => $status,
]));
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3620,7 +3630,7 @@ Http::post('/v1/messaging/messages/push')
$pushData['priority'] = $priority;
}
$message = $dbForProject->createDocument('messages', new Document([
$message = new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_PUSH,
'topics' => $topics,
@@ -3629,7 +3639,12 @@ Http::post('/v1/messaging/messages/push')
'scheduledAt' => $scheduledAt,
'data' => $pushData,
'status' => $status,
]));
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
switch ($status) {
case MessageStatus::PROCESSING:
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -73,8 +73,9 @@ 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): Document
{
$name = $name ?? '';
$plaintextPassword = $password;
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
@@ -255,7 +256,7 @@ 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) {
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$plaintext = new Plaintext();
$user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
@@ -291,7 +292,7 @@ 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) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$bcrypt = new Bcrypt();
$bcrypt->setCost(8); // Default cost
@@ -329,7 +330,7 @@ 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) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$md5 = new MD5();
$user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -366,7 +367,7 @@ 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) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$argon2 = new Argon2();
$user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -404,7 +405,7 @@ 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) {
->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$sha = new Sha();
if (!empty($passwordVersion)) {
$sha->setVersion($passwordVersion);
@@ -444,7 +445,7 @@ 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) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$phpass = new PHPass();
$user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -486,7 +487,7 @@ 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) {
->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) {
$scrypt = new Scrypt();
$scrypt
->setSalt($passwordSalt)
@@ -532,7 +533,7 @@ 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) {
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scryptModified = new ScryptModified();
$scryptModified
->setSalt($passwordSalt)
+14 -8
View File
@@ -27,6 +27,7 @@ use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
@@ -929,14 +930,19 @@ Http::shutdown()
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
$now = DateTime::now();
if ($cacheLog->isEmpty()) {
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
try {
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
} catch (DuplicateException) {
// Race condition: another concurrent request already created the cache document
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
}
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
$cacheLog->setAttribute('accessedAt', $now);
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog));
+1
View File
@@ -6,6 +6,7 @@ const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
const APP_VIEWS_DIR = __DIR__ . '/../views';
const APP_CE_CONFIG_DIR = __DIR__ . '/../config';
// Email
const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address
+4
View File
@@ -88,6 +88,10 @@ Database::addFilter(
break;
case Database::VAR_STRING:
case Database::VAR_VARCHAR:
case Database::VAR_TEXT:
case Database::VAR_MEDIUMTEXT:
case Database::VAR_LONGTEXT:
$filters = $attribute->getAttribute('filters', []);
$attribute->setAttribute('encrypt', in_array('encrypt', $filters));
break;
+69 -41
View File
@@ -248,48 +248,62 @@ $adapter
$server = new Server($adapter);
$logError = function (Throwable $error, string $action) use ($register) {
$logger = $register->get('realtimeLogger');
// Allows overriding
if (!function_exists('logError')) {
function logError(Throwable $error, string $action, array $tags = [], ?Document $project = null, ?Document $user = null, ?Authorization $authorization = null): void
{
global $register;
if ($logger && !$error instanceof Exception) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$logger = $register->get('realtimeLogger');
$log = new Log();
$log->setNamespace("realtime");
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
if ($logger && !$error instanceof Exception) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log = new Log();
$log->setNamespace("realtime");
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log->addTag('projectId', $project?->getId() ?: 'n/a');
$log->addTag('userId', $user?->getId() ?: 'n/a');
$log->setAction($action);
foreach ($tags as $key => $value) {
$log->addTag($key, $value ?: 'n/a');
}
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
$log->addExtra('detailedTrace', $error->getTrace());
$log->addExtra('roles', $authorization?->getRoles() ?? []);
try {
$responseCode = $logger->addLog($log);
Console::info('Error log pushed with status code: ' . $responseCode);
} catch (Throwable $th) {
Console::error('Error pushing log: ' . $th->getMessage());
$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::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $error->getMessage());
Console::error('[Error] File: ' . $error->getFile());
Console::error('[Error] Line: ' . $error->getLine());
}
}
Console::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $error->getMessage());
Console::error('[Error] File: ' . $error->getFile());
Console::error('[Error] Line: ' . $error->getLine());
};
$server->error(logError(...));
$server->error($logError);
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument, $logError) {
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
sleep(5); // wait for the initial database schema to be ready
Console::success('Server started successfully');
@@ -326,7 +340,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, $logError) {
Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
$payload = [];
foreach ($stats as $projectId => $value) {
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
@@ -344,13 +358,13 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument));
} catch (Throwable $th) {
$logError($th, "updateWorkerDocument");
logError($th, "updateWorkerDocument");
}
});
}
});
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime, $logError) {
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) {
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
@@ -362,7 +376,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$attempts = 0;
$start = time();
Timer::tick(5000, function () use ($server, $register, $realtime, $stats, $logError) {
Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
/**
* Sending current connections to project channels on the console project every 5 seconds.
*/
@@ -548,7 +562,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
}
});
} catch (Throwable $th) {
$logError($th, "pubSubConnection");
logError($th, "pubSubConnection");
Console::error('Pub/sub error: ' . $th->getMessage());
$attempts++;
@@ -560,7 +574,7 @@ $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, $logError) {
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
$app = new Http('UTC');
$request = new Request($request);
$response = new Response(new SwooleResponse());
@@ -571,6 +585,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
Http::setResource('request', fn () => $request);
Http::setResource('response', fn () => $response);
$project = null;
$logUser = null;
$authorization = null;
try {
/** @var Document $project */
$project = $app->getResource('project');
@@ -591,8 +609,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
$projectRegion = $project->getAttribute('region', '');
$currentRegion = System::getEnv('_APP_REGION', 'default');
if (!empty($projectRegion) && $projectRegion !== $currentRegion) {
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
@@ -683,11 +708,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$stats->incr($project->getId(), 'connections');
$stats->incr($project->getId(), 'connectionsTotal');
} catch (Throwable $th) {
$logError($th, "initServer");
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
// Handle SQL error code is 'HY000'
$code = $th->getCode();
if (!is_int($code)) {
if (!\is_int($code)) {
$code = 500;
}
@@ -718,7 +743,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId, $logError) {
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
$project = null;
$authorization = null;
try {
$response = new Response(new SwooleResponse());
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
@@ -844,7 +872,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
} catch (Throwable $th) {
$logError($th, "realtimeMessage");
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
+1 -1
View File
@@ -79,7 +79,7 @@
"utopia-php/storage": "1.0.*",
"utopia-php/system": "0.10.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/vcs": "1.*",
"utopia-php/vcs": "2.*",
"utopia-php/websocket": "1.0.*",
"matomo/device-detector": "6.4.*",
"dragonmantank/cron-expression": "3.4.*",
Generated
+38 -38
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cb2ca42cfed8603c31b75113c85a4699",
"content-hash": "1fb043a556550f62ab27a0aad0b9332a",
"packages": [
{
"name": "adhocore/jwt",
@@ -4517,16 +4517,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.6.1",
"version": "1.6.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "c5c7544d02d2418536d41050794050132f247d62"
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c5c7544d02d2418536d41050794050132f247d62",
"reference": "c5c7544d02d2418536d41050794050132f247d62",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/037bf4b3813d44f1b0990bc124e35b501ed27fca",
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca",
"shasum": ""
},
"require": {
@@ -4566,9 +4566,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.6.1"
"source": "https://github.com/utopia-php/migration/tree/1.6.2"
},
"time": "2026-02-17T05:49:48+00:00"
"time": "2026-02-25T12:00:11+00:00"
},
{
"name": "utopia-php/mongo",
@@ -4684,16 +4684,16 @@
},
{
"name": "utopia-php/pools",
"version": "1.0.2",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/pools.git",
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1"
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"shasum": ""
},
"require": {
@@ -4731,9 +4731,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/pools/issues",
"source": "https://github.com/utopia-php/pools/tree/1.0.2"
"source": "https://github.com/utopia-php/pools/tree/1.0.3"
},
"time": "2026-01-28T13:12:36+00:00"
"time": "2026-02-26T08:42:40+00:00"
},
{
"name": "utopia-php/preloader",
@@ -5215,16 +5215,16 @@
},
{
"name": "utopia-php/vcs",
"version": "1.0.1",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
"reference": "0f942f667319bb22cd420431e4e79107b41061f3"
"reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/0f942f667319bb22cd420431e4e79107b41061f3",
"reference": "0f942f667319bb22cd420431e4e79107b41061f3",
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/92a1650824ba0c5e6a1bc46e622ac87c50a08920",
"reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920",
"shasum": ""
},
"require": {
@@ -5258,9 +5258,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
"source": "https://github.com/utopia-php/vcs/tree/1.0.1"
"source": "https://github.com/utopia-php/vcs/tree/2.0.1"
},
"time": "2026-02-06T10:11:58+00:00"
"time": "2026-02-27T12:18:49+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5438,16 +5438,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "1.11.1",
"version": "1.11.3",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb"
"reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb",
"reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/45d22c0107a53bb9a0a4e39db0e738d461631d11",
"reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11",
"shasum": ""
},
"require": {
@@ -5483,22 +5483,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.11.1"
"source": "https://github.com/appwrite/sdk-generator/tree/1.11.3"
},
"time": "2026-02-25T07:15:19+00:00"
"time": "2026-02-27T06:54:59+00:00"
},
{
"name": "brianium/paratest",
"version": "v7.19.0",
"version": "v7.19.1",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6"
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/95b03194f4cdf5c83175ceead673e21cb66465e7",
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7",
"shasum": ""
},
"require": {
@@ -5512,7 +5512,7 @@
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
"phpunit/php-file-iterator": "^6.0.1 || ^7",
"phpunit/php-timer": "^8 || ^9",
"phpunit/phpunit": "^12.5.9 || ^13",
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
"sebastian/environment": "^8.0.3 || ^9",
"symfony/console": "^7.4.4 || ^8.0.4",
"symfony/process": "^7.4.5 || ^8.0.5"
@@ -5522,10 +5522,10 @@
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
"phpstan/phpstan": "^2.1.38",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.12",
"phpstan/phpstan-strict-rules": "^2.0.8",
"phpstan/phpstan": "^2.1.40",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
"symfony/filesystem": "^7.4.0 || ^8.0.1"
},
"bin": [
@@ -5566,7 +5566,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.19.0"
"source": "https://github.com/paratestphp/paratest/tree/v7.19.1"
},
"funding": [
{
@@ -5578,7 +5578,7 @@
"type": "paypal"
}
],
"time": "2026-02-06T10:53:26+00:00"
"time": "2026-02-25T14:53:45+00:00"
},
{
"name": "doctrine/annotations",
@@ -9043,7 +9043,7 @@
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {},
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -9067,5 +9067,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+66 -8
View File
@@ -518,10 +518,11 @@ class Event
*
* @param string $pattern
* @param array $params
* @param ?Document $database
* @return array
* @throws \InvalidArgumentException
*/
public static function generateEvents(string $pattern, array $params = []): array
public static function generateEvents(string $pattern, array $params = [], ?Document $database = null): array
{
// $params = \array_filter($params, fn($param) => !\is_array($param));
$paramKeys = \array_keys($params);
@@ -530,6 +531,12 @@ class Event
$patterns = [];
$parsed = self::parseEventPattern($pattern);
// to switch the resource types from databases to the required prefix
// eg; all databases events get fired with databases. prefix which mainly depicts legacy type
// so a projection from databases to the actual prefix
if ((str_contains($pattern, 'databases.') && $database && $database->getAttribute('type') !== 'legacy')) {
$parsed = self::getDatabaseTypeEvents($database, $parsed);
}
$type = $parsed['type'];
$resource = $parsed['resource'];
$subType = $parsed['subType'];
@@ -630,8 +637,8 @@ class Event
$eventValues = \array_values($events);
/**
* Return a combined list of table, collection events.
*/
* Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility
*/
return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues);
}
@@ -671,16 +678,41 @@ class Event
'attributes' => 'columns',
];
$databasesEventMap = [
'tablesdb' => 'databases',
'tables' => 'collections',
'rows' => 'documents',
'columns' => 'attributes'
];
if (
str_contains($pattern, 'databases.') &&
str_contains($firstEvent, 'collections')
(
str_contains($pattern, 'databases.') &&
str_contains($firstEvent, 'collections')
) ||
(
str_contains($firstEvent, 'tablesdb.')
)
) {
$pairedEvents = [];
foreach ($events as $event) {
$pairedEvents[] = $event;
if (str_contains($event, 'collections')) {
// tablesdb needs databases event with tables and collections
if (str_contains($event, 'tablesdb')) {
$databasesSideEvent = str_replace(
array_keys($databasesEventMap),
array_values($databasesEventMap),
$event
);
$pairedEvents[] = $databasesSideEvent;
$tableSideEvent = str_replace(
array_keys($tableEventMap),
array_values($tableEventMap),
$databasesSideEvent
);
$pairedEvents[] = $tableSideEvent;
} elseif (str_contains($event, 'collections')) {
$tableSideEvent = str_replace(
array_keys($tableEventMap),
array_values($tableEventMap),
@@ -692,8 +724,34 @@ class Event
$events = $pairedEvents;
}
// mirrored events can have duplicates in case of smaller events
// array unique can turns list to hasmap in case duplicates present
// so forcing array value will turn this to array list always
return array_values(array_unique($events));
}
return $events;
/**
* Maps event terminology based on database type
*/
private static function getDatabaseTypeEvents(Document $database, array $event): array
{
$eventMap = [];
switch ($database->getAttribute('type')) {
case 'tablesdb':
$eventMap = [
'databases' => 'tablesdb',
'documents' => 'rows',
'collections' => 'tables',
'attributes' => 'columns',
];
break;
}
foreach ($event as $eventKey => $eventValue) {
if (isset($eventMap[$eventValue])) {
$event[$eventKey] = $eventMap[$eventValue];
}
}
return $event;
}
/**
+1 -1
View File
@@ -72,7 +72,7 @@ class Realtime extends Event
return false;
}
$allEvents = Event::generateEvents($this->getEvent(), $this->getParams());
$allEvents = Event::generateEvents($this->getEvent(), $this->getParams(), $this->getContext('database'));
$payload = new Document($this->getPayload());
+2
View File
@@ -166,6 +166,7 @@ class Exception extends \Exception
/** Sites */
public const string SITE_NOT_FOUND = 'site_not_found';
public const string SITE_ALREADY_EXISTS = 'site_already_exists';
public const string SITE_TEMPLATE_NOT_FOUND = 'site_template_not_found';
/** Functions */
@@ -365,6 +366,7 @@ class Exception extends \Exception
/** Message */
public const string MESSAGE_NOT_FOUND = 'message_not_found';
public const string MESSAGE_ALREADY_EXISTS = 'message_already_exists';
public const string MESSAGE_MISSING_TARGET = 'message_missing_target';
public const string MESSAGE_ALREADY_SENT = 'message_already_sent';
public const string MESSAGE_ALREADY_PROCESSING = 'message_already_processing';
+60 -7
View File
@@ -491,6 +491,7 @@ class Realtime extends MessagingAdapter
$roles = [Role::team(ID::custom($parts[1]))->toString()];
break;
case 'databases':
case 'tablesdb':
$resource = $parts[4] ?? '';
if (in_array($resource, ['columns', 'attributes', 'indexes'])) {
$channels[] = 'console';
@@ -508,14 +509,14 @@ class Realtime extends MessagingAdapter
$tableId = $payload->getAttribute('$tableId', '');
$collectionId = $payload->getAttribute('$collectionId', '');
$resourceId = $tableId ?: $collectionId;
$channels = [];
$channels[] = 'rows';
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows';
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows.' . $payload->getId();
$channels[] = 'documents';
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents';
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents.' . $payload->getId();
// sending legacy + tablesdb events to both legacy and tablesdb
$channels = array_values(array_unique(array_merge(
self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'),
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'),
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId())
)));
$roles = $collection->getAttribute('documentSecurity', false)
? \array_merge($collection->getRead(), $payload->getRead())
@@ -572,4 +573,56 @@ class Realtime extends MessagingAdapter
'projectId' => $projectId
];
}
/**
* Generate realtime channels for database events
*
* @param string $type The database API type
* @param string $databaseId The database ID
* @param string $resourceId The collection/table ID
* @param string $payloadId The document/row ID
* @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes
* @return array Array of channel names
*/
private static function getDatabaseChannels(
string $type = 'databases',
string $databaseId = '',
string $resourceId = '',
string $payloadId = '',
string $prefixOverride = '',
): array {
$basePrefix = $prefixOverride ?: $type;
if (!$databaseId || !$resourceId || !$payloadId) {
return [];
}
$channels = [];
switch ($type) {
case 'legacy':
if (empty($prefixOverride)) {
$basePrefix = 'databases';
}
$channels[] = 'documents';
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
break;
case 'tablesdb':
$channels[] = 'rows';
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows";
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows.{$payloadId}";
break;
default:
$basePrefix = 'databases';
$channels[] = 'documents';
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
break;
}
return $channels;
}
}
+2
View File
@@ -13,6 +13,7 @@ use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
use Appwrite\Platform\Modules\Sites;
use Appwrite\Platform\Modules\Storage;
use Appwrite\Platform\Modules\Teams;
use Appwrite\Platform\Modules\Tokens;
use Appwrite\Platform\Modules\VCS;
use Utopia\Platform\Platform;
@@ -31,6 +32,7 @@ class Appwrite extends Platform
$this->addModule(new Sites\Module());
$this->addModule(new Console\Module());
$this->addModule(new Proxy\Module());
$this->addModule(new Teams\Module());
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
@@ -24,6 +24,7 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends Action
@@ -70,15 +71,17 @@ 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)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
@@ -129,9 +132,73 @@ class XList extends Action
$documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries);
$total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0;
} elseif (! empty($selectQueries)) {
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
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 = $dbForProject->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');
} else {
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
} else {
// has no selects, disable relationship loading on documents
/* @type Document[] $documents */
@@ -14,6 +14,7 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends DocumentXList
@@ -56,8 +57,10 @@ 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)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
@@ -90,6 +90,15 @@ class Delete extends Base
}
$status = $execution->getAttribute('status');
// Treat timed-out executions as failed so they can be deleted.
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$status = 'failed';
}
}
if (!in_array($status, ['completed', 'failed', 'scheduled'])) {
throw new Exception(Exception::EXECUTION_IN_PROGRESS);
}
@@ -82,6 +82,16 @@ class Get extends Base
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
// Override status in response if the execution is stuck in waiting/processing beyond the function timeout.
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
$response->dynamic($execution, Response::MODEL_EXECUTION);
}
}
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -110,6 +111,35 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing execution is considered timed out.
$timeout = $function->getAttribute('timeout', 900);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing executions created before the timeout threshold, so timed-out
// executions that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -119,6 +149,20 @@ class XList extends Base
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.");
}
// Override status in response for timed-out executions, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $execution) {
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -71,6 +71,16 @@ class Get extends Base
throw new Exception(Exception::LOG_NOT_FOUND);
}
// Override status in response if the log is stuck in waiting/processing beyond the site timeout.
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $site->getAttribute('timeout', 30);
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
$response->dynamic($log, Response::MODEL_EXECUTION); //TODO: Change to model log, but model log already exists - decide what to do
}
}
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Database\Validator\Queries\Logs;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -99,6 +100,35 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing log is considered timed out.
$timeout = $site->getAttribute('timeout', 30);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing logs created before the timeout threshold, so timed-out
// logs that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -108,6 +138,20 @@ class XList extends Base
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.");
}
// Override status in response for timed-out logs, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $log) {
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -136,7 +137,7 @@ class Create extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
$site = $dbForProject->createDocument('sites', new Document([
$site = new Document([
'$id' => $siteId,
'enabled' => $enabled,
'live' => true,
@@ -166,13 +167,17 @@ class Create extends Base
'runtimeSpecification' => $specification,
'buildRuntime' => $buildRuntime,
'adapter' => $adapter,
]));
]);
try {
$site = $dbForProject->createDocument('sites', $site);
} catch (DuplicateException) {
throw new Exception(Exception::SITE_ALREADY_EXISTS);
}
// Git connect logic
if (!empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = $dbForPlatform->createDocument('repositories', new Document([
$repository = new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -184,8 +189,8 @@ class Create extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]));
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
$site->setAttribute('repositoryId', $repository->getId());
$site->setAttribute('repositoryInternalId', $repository->getSequence());
}
@@ -190,11 +190,9 @@ class Update extends Base
$repositoryInternalId = '';
}
// Git connect logic
if (!$isConnected && !empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = $dbForPlatform->createDocument('repositories', new Document([
$repository = new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -206,8 +204,8 @@ class Update extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]));
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
}
@@ -0,0 +1,137 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Logs;
use Appwrite\Detector\Detector;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use MaxMind\Db\Reader;
use Utopia\Audit\Audit;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Queries;
use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset;
use Utopia\Database\Validator\UID;
use Utopia\Locale\Locale;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listTeamLogs';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/logs')
->desc('List team logs')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'logs',
name: 'listLogs',
description: '/docs/references/teams/get-team-logs.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_LOG_LIST,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', 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('dbForProject')
->inject('locale')
->inject('geodb')
->inject('audit')
->callback($this->action(...));
}
public function action(string $teamId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Audit $audit)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$grouped = Query::groupByType($queries);
$limit = $grouped['limit'] ?? 25;
$offset = $grouped['offset'] ?? 0;
$resource = 'team/' . $team->getId();
$logs = $audit->getLogsByResource($resource, offset: $offset, limit: $limit);
$output = [];
foreach ($logs as $i => &$log) {
$log['userAgent'] = (!empty($log['userAgent'])) ? $log['userAgent'] : 'UNKNOWN';
$detector = new Detector($log['userAgent']);
$detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then)
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
$output[$i] = new Document([
'event' => $log['event'],
'userId' => $log['data']['userId'],
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
'osName' => $os['osName'],
'osVersion' => $os['osVersion'],
'clientType' => $client['clientType'],
'clientCode' => $client['clientCode'],
'clientName' => $client['clientName'],
'clientVersion' => $client['clientVersion'],
'clientEngine' => $client['clientEngine'],
'clientEngineVersion' => $client['clientEngineVersion'],
'deviceName' => $device['deviceName'],
'deviceBrand' => $device['deviceBrand'],
'deviceModel' => $device['deviceModel']
]);
$record = $geodb->get($log['ip']);
if ($record) {
$output[$i]['countryCode'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), false) ? \strtolower($record['country']['iso_code']) : '--';
$output[$i]['countryName'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), $locale->getText('locale.country.unknown'));
} else {
$output[$i]['countryCode'] = '--';
$output[$i]['countryName'] = $locale->getText('locale.country.unknown');
}
}
$response->dynamic(new Document([
'total' => $includeTotal ? $audit->countLogsByResource($resource) : 0,
'logs' => $output,
]), Response::MODEL_LOG_LIST);
}
}
@@ -0,0 +1,434 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\Network\Validator\Email as EmailValidator;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Database\Validator\UID;
use Utopia\Emails\Email;
use Utopia\Locale\Locale;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/teams/:teamId/memberships')
->desc('Create team membership')
->groups(['api', 'teams', 'auth'])
->label('event', 'teams.[teamId].memberships.[membershipId].create')
->label('scope', 'teams.write')
->label('auth.type', 'invites')
->label('audits.event', 'membership.create')
->label('audits.resource', 'team/{request.teamId}')
->label('audits.userId', '{request.userId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'createMembership',
description: '/docs/references/teams/create-team-membership.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->label('abuse-limit', 10)
->param('teamId', '', new UID(), 'Team ID.')
->param('email', '', new EmailValidator(), 'Email of the new team member.', true)
->param('userId', '', new UID(), 'ID of the user to be added to a team.', true)
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
->param('roles', [], new ArrayList(new Key(maxLength: 81), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 81 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page
->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true)
->inject('response')
->inject('project')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->inject('locale')
->inject('queueForMails')
->inject('queueForMessaging')
->inject('queueForEvents')
->inject('timelimit')
->inject('queueForStatsUsage')
->inject('plan')
->inject('proofForPassword')
->inject('proofForToken')
->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, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken)
{
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
if (empty($url)) {
if (!$isAppUser && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required');
}
}
if (empty($userId) && empty($email) && empty($phone)) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'At least one of userId, email, or phone is required');
}
if (!$isPrivilegedUser && !$isAppUser && empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED);
}
$email = \strtolower($email);
$name = empty($name) ? $email : $name;
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if (!empty($userId)) {
$invitee = $dbForProject->getDocument('users', $userId);
if ($invitee->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND, 'User with given userId doesn\'t exist.', 404);
}
if (!empty($email) && $invitee->getAttribute('email', '') !== $email) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and email doesn\'t match', 409);
}
if (!empty($phone) && $invitee->getAttribute('phone', '') !== $phone) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and phone doesn\'t match', 409);
}
$email = $invitee->getAttribute('email', '');
$phone = $invitee->getAttribute('phone', '');
$name = $invitee->getAttribute('name', '') ?: $name;
} elseif (!empty($email)) {
$invitee = $dbForProject->findOne('users', [Query::equal('email', [$email])]); // Get user by email address
if (!$invitee->isEmpty() && !empty($phone) && $invitee->getAttribute('phone', '') !== $phone) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given email and phone doesn\'t match', 409);
}
} 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
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if (!$isPrivilegedUser && !$isAppUser && $limit !== 0 && $project->getId() !== 'console') { // check users limit, console invites are allways allowed.
$total = $dbForProject->count('users', [], APP_LIMIT_USERS);
if ($total >= $limit) {
throw new Exception(Exception::USER_COUNT_EXCEEDED, 'Project registration is restricted. Contact your administrator for more information.');
}
}
// Makes sure this email is not already used in another identity
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
if (!$identityWithMatchingEmail->isEmpty()) {
throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS);
}
try {
$userId = ID::unique();
$hash = $proofForPassword->hash($proofForPassword->generate());
$emailCanonical = new Email($email);
} catch (Throwable) {
$emailCanonical = null;
}
$userId = ID::unique();
$userDocument = new Document([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
Permission::read(Role::user($userId)),
Permission::update(Role::user($userId)),
Permission::delete(Role::user($userId)),
],
'email' => empty($email) ? null : $email,
'phone' => empty($phone) ? null : $phone,
'emailVerification' => false,
'status' => true,
// TODO: Set password empty?
'password' => $hash,
'hash' => $proofForPassword->getHash()->getName(),
'hashOptions' => $proofForPassword->getHash()->getOptions(),
/**
* Set the password update time to 0 for users created using
* team invite and OAuth to allow password updates without an
* old password
*/
'passwordUpdate' => null,
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
'prefs' => new \stdClass(),
'sessions' => null,
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $name]),
'emailCanonical' => $emailCanonical?->getCanonical(),
'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
'emailIsCorporate' => $emailCanonical?->isCorporate(),
'emailIsDisposable' => $emailCanonical?->isDisposable(),
'emailIsFree' => $emailCanonical?->isFree(),
]);
try {
$invitee = $authorization->skip(fn () => $dbForProject->createDocument('users', $userDocument));
} catch (Duplicate $th) {
throw new Exception(Exception::USER_ALREADY_EXISTS);
}
}
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server)
throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to send invitations for this team');
}
$membership = $dbForProject->findOne('memberships', [
Query::equal('userInternalId', [$invitee->getSequence()]),
Query::equal('teamInternalId', [$team->getSequence()]),
]);
$secret = $proofForToken->generate();
if ($membership->isEmpty()) {
$membershipId = ID::unique();
$membership = new Document([
'$id' => $membershipId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::user($invitee->getId())),
Permission::update(Role::team($team->getId(), 'owner')),
Permission::delete(Role::user($invitee->getId())),
Permission::delete(Role::team($team->getId(), 'owner')),
],
'userId' => $invitee->getId(),
'userInternalId' => $invitee->getSequence(),
'teamId' => $team->getId(),
'teamInternalId' => $team->getSequence(),
'roles' => $roles,
'invited' => DateTime::now(),
'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null,
'confirm' => ($isPrivilegedUser || $isAppUser),
'secret' => $proofForToken->hash($secret),
'search' => implode(' ', [$membershipId, $invitee->getId()])
]);
$membership = ($isPrivilegedUser || $isAppUser) ?
$authorization->skip(fn () => $dbForProject->createDocument('memberships', $membership)) :
$dbForProject->createDocument('memberships', $membership);
if ($isPrivilegedUser || $isAppUser) {
$authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1));
}
} elseif ($membership->getAttribute('confirm') === false) {
$membership->setAttribute('secret', $proofForToken->hash($secret));
$membership->setAttribute('invited', DateTime::now());
if ($isPrivilegedUser || $isAppUser) {
$membership->setAttribute('joined', DateTime::now());
$membership->setAttribute('confirm', true);
}
$membership = ($isPrivilegedUser || $isAppUser) ?
$authorization->skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) :
$dbForProject->updateDocument('memberships', $membership->getId(), $membership);
} else {
throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED);
}
if ($isPrivilegedUser || $isAppUser) {
$dbForProject->purgeCachedDocument('users', $invitee->getId());
} else {
$url = Template::parseURL($url);
$url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['membershipId' => $membership->getId(), 'userId' => $invitee->getId(), 'secret' => $secret, 'teamId' => $teamId, 'teamName' => $team->getAttribute('name')]);
$url = Template::unParseURL($url);
if (!empty($email)) {
$projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]');
$body = $locale->getText("emails.invitation.body");
$preview = $locale->getText("emails.invitation.preview");
$subject = $locale->getText("emails.invitation.subject");
$customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? [];
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl');
$message
->setParam('{{body}}', $body, escapeHtml: false)
->setParam('{{hello}}', $locale->getText("emails.invitation.hello"))
->setParam('{{footer}}', $locale->getText("emails.invitation.footer"))
->setParam('{{thanks}}', $locale->getText("emails.invitation.thanks"))
->setParam('{{buttonText}}', $locale->getText("emails.invitation.buttonText"))
->setParam('{{signature}}', $locale->getText("emails.invitation.signature"));
$body = $message->render();
$smtp = $project->getAttribute('smtp', []);
$smtpEnabled = $smtp['enabled'] ?? false;
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
$senderEmail = $smtp['senderEmail'];
}
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
}
$queueForMails
->setSmtpHost($smtp['host'] ?? '')
->setSmtpPort($smtp['port'] ?? '')
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
}
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
$emailVariables = [
'owner' => $user->getAttribute('name'),
'direction' => $locale->getText('settings.direction'),
/* {{user}}, {{team}}, {{redirect}} and {{project}} are required in default and custom templates */
'user' => $name,
'team' => $team->getAttribute('name'),
'redirect' => $url,
'project' => $projectName
];
$queueForMails
->setSubject($subject)
->setBody($body)
->setPreview($preview)
->setRecipient($invitee->getAttribute('email'))
->setName($invitee->getAttribute('name', ''))
->appendVariables($emailVariables)
->trigger();
} elseif (!empty($phone)) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'];
}
$message = $message->setParam('{{token}}', $url);
$message = $message->render();
$messageDoc = new Document([
'$id' => ID::unique(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$phone])
->setProviderType('SMS');
$helper = PhoneNumberUtil::getInstance();
try {
$countryCode = $helper->parse($phone)->getCountryCode();
if (!empty($countryCode)) {
$queueForStatsUsage
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
}
} catch (NumberParseException $e) {
// Ignore invalid phone number for country code stats
}
$queueForStatsUsage
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
->setProject($project)
->trigger();
}
}
$queueForEvents
->setParam('userId', $invitee->getId())
->setParam('teamId', $team->getId())
->setParam('membershipId', $membership->getId())
;
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(
$membership
->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $invitee->getAttribute('name'))
->setAttribute('userEmail', $invitee->getAttribute('email')),
Response::MODEL_MEMBERSHIP
);
}
}
@@ -0,0 +1,155 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Authorization as AuthorizationException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
->desc('Delete team membership')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].memberships.[membershipId].delete')
->label('scope', 'teams.write')
->label('audits.event', 'membership.delete')
->label('audits.resource', 'team/{request.teamId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'deleteMembership',
description: '/docs/references/teams/delete-team-membership.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->inject('user')
->inject('project')
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, Document $user, Document $project, Response $response, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty()) {
throw new Exception(Exception::TEAM_INVITE_NOT_FOUND);
}
$profile = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
if ($profile->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if ($membership->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
}
$this->validate($profile, $team, $dbForProject);
if ($project->getId() === 'console') {
// Quick check:
// fetch up to 2 owners to determine if only one exists
$ownersCount = $dbForProject->count(
collection: 'memberships',
queries: [
Query::contains('roles', ['owner']),
Query::equal('teamInternalId', [$team->getSequence()])
],
max: 2
);
// Is the deletion being requested by the user on their own membership and they are also the owner?
$isSelfOwner =
in_array('owner', $membership->getAttribute('roles')) &&
$membership->getAttribute('userInternalId') === $user->getSequence();
if ($ownersCount === 1 && $isSelfOwner) {
/**
* Prevent removal if the user is the only owner, this is because -
*
* 1. Other roles [if exists] can neither add a new owner nor delete the organization.
* 2. If the only owner is removed, while there were no other members, the organization isn't marked for deletion and stays in a limbo.
*/
throw new Exception(Exception::MEMBERSHIP_DELETION_PROHIBITED, 'There must be at least one owner in the organization.');
}
}
try {
$dbForProject->deleteDocument('memberships', $membership->getId());
} catch (AuthorizationException $exception) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}
$dbForProject->purgeCachedDocument('users', $profile->getId());
// This membership is primary for the team, update the primary to next member.
if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) {
$membership = $dbForProject->findOne('memberships', [
Query::equal('teamInternalId', [$team->getSequence()]),
]);
if (!$membership->isEmpty()) {
$team->setAttribute('userId', $membership->getAttribute('userId'));
$team->setAttribute('userInternalId', $membership->getAttribute('userInternalId'));
$dbForProject->updateDocument('teams', $team->getId(), $team);
}
}
if ($membership->getAttribute('confirm')) { // Count only confirmed members
$authorization->skip(fn () => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0));
}
$queueForEvents
->setParam('teamId', $team->getId())
->setParam('userId', $profile->getId())
->setParam('membershipId', $membership->getId())
->setPayload($response->output($membership, Response::MODEL_MEMBERSHIP));
$response->noContent();
}
protected function validate(Document $profile, Document $team, Database $dbForProject): void
{
return;
}
}
@@ -0,0 +1,119 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
->desc('Get team membership')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'getMembership',
description: '/docs/references/teams/get-team-member.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty() || empty($membership->getAttribute('userId'))) {
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
];
$roles = $authorization->getRoles();
$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))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
$mfa = $user->getAttribute('mfa', false);
if ($mfa) {
$totp = TOTP::getAuthenticatorFromUser($user);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
}
}
$membership->setAttribute('mfa', $mfa);
}
if ($membershipsPrivacy['userName']) {
$membership->setAttribute('userName', $user->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
$membership->setAttribute('userEmail', $user->getAttribute('email'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
$response->dynamic($membership, Response::MODEL_MEMBERSHIP);
}
}
@@ -0,0 +1,212 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships\Status;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use MaxMind\Db\Reader;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamMembershipStatus';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId/status')
->desc('Update team membership status')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].memberships.[membershipId].update.status')
->label('scope', 'public')
->label('audits.event', 'membership.update')
->label('audits.resource', 'team/{request.teamId}')
->label('audits.userId', '{request.userId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'updateMembershipStatus',
description: '/docs/references/teams/update-team-membership-status.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->param('userId', '', new UID(), 'User ID.')
->param('secret', '', new Text(256), 'Secret key.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->inject('project')
->inject('geodb')
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
->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)
{
$protocol = $request->getProtocol();
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty()) {
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
$team = $authorization->skip(fn () => $dbForProject->getDocument('teams', $teamId));
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if ($membership->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
}
if (!$proofForToken->verify($secret, $membership->getAttribute('secret'))) {
throw new Exception(Exception::TEAM_INVALID_SECRET);
}
if ($userId !== $membership->getAttribute('userId')) {
throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')');
}
$hasSession = !$user->isEmpty();
if (!$hasSession) {
$user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user
}
if ($membership->getAttribute('userInternalId') !== $user->getSequence()) {
throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')');
}
if ($membership->getAttribute('confirm') === true) {
throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED);
}
$membership // Attach user to team
->setAttribute('joined', DateTime::now())
->setAttribute('confirm', true)
;
$authorization->skip(fn () => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
// Create session for the user if not logged in
if (!$hasSession) {
$authorization->addRole(Role::user($user->getId())->toString());
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::addSeconds(new \DateTime(), $authDuration);
$secret = $proofForToken->generate();
$session = new Document(array_merge([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
],
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'provider' => SESSION_PROVIDER_EMAIL,
'providerUid' => $user->getAttribute('email'),
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $authDuration)
], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
$session = $dbForProject->createDocument('sessions', $session);
$authorization->addRole(Role::user($userId)->toString());
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$response
->addCookie(
name: $store->getKey() . '_legacy',
value: $encoded,
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
domain: Config::getParam('cookieDomain'),
secure: ('https' === $protocol),
httponly: true
)
->addCookie(
name: $store->getKey(),
value: $encoded,
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
domain: Config::getParam('cookieDomain'),
secure: ('https' === $protocol),
httponly: true,
sameSite: Config::getParam('cookieSamesite')
)
;
}
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership);
$dbForProject->purgeCachedDocument('users', $user->getId());
$authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1));
$queueForEvents
->setParam('userId', $user->getId())
->setParam('teamId', $team->getId())
->setParam('membershipId', $membership->getId())
;
$response->dynamic(
$membership
->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $user->getAttribute('name'))
->setAttribute('userEmail', $user->getAttribute('email')),
Response::MODEL_MEMBERSHIP
);
}
}
@@ -0,0 +1,139 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
->desc('Update team membership')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].memberships.[membershipId].update')
->label('scope', 'teams.write')
->label('audits.event', 'membership.update')
->label('audits.resource', 'team/{request.teamId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'updateMembership',
description: '/docs/references/teams/update-team-membership.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->param('roles', [], new ArrayList(new Key(maxLength: 81), APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 81 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
->inject('request')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('authorization')
->inject('queueForEvents')
->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)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty()) {
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
$profile = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
if ($profile->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if ($project->getId() === 'console') {
// Quick check: fetch up to 2 owners to determine if only one exists
$ownersCount = $dbForProject->count(
collection: 'memberships',
queries: [
Query::contains('roles', ['owner']),
Query::equal('teamInternalId', [$team->getSequence()])
],
max: 2
);
// Is the role change being requested by the user on their own membership?
$isCurrentUserAnOwner = $user->getSequence() === $membership->getAttribute('userInternalId');
// Prevent role change if there's only one owner left,
// the requester is that owner, and the new `$roles` no longer include 'owner'
if ($ownersCount === 1 && $isOwner && $isCurrentUserAnOwner && !\in_array('owner', $roles)) {
throw new Exception(Exception::MEMBERSHIP_DOWNGRADE_PROHIBITED, 'There must be at least one owner in the organization.');
}
}
if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server)
throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to modify roles');
}
/**
* Update the roles
*/
$membership->setAttribute('roles', $roles);
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership);
/**
* Replace membership on profile
*/
$dbForProject->purgeCachedDocument('users', $profile->getId());
$queueForEvents
->setParam('userId', $profile->getId())
->setParam('teamId', $team->getId())
->setParam('membershipId', $membership->getId());
$response->dynamic(
$membership
->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $profile->getAttribute('name'))
->setAttribute('userEmail', $profile->getAttribute('email')),
Response::MODEL_MEMBERSHIP
);
}
}
@@ -0,0 +1,179 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\Memberships;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listTeamMemberships';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/memberships')
->desc('List team memberships')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'listMemberships',
description: '/docs/references/teams/list-team-members.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP_LIST,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('queries', [], new Memberships(), '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(', ', Memberships::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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('project')
->inject('dbForProject')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
// Set internal queries
$queries[] = Query::equal('teamInternalId', [$team->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());
}
$membershipId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('memberships', $membershipId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Membership '{$membershipId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$memberships = $dbForProject->find(
collection: 'memberships',
queries: $queries,
);
$total = $includeTotal ? $dbForProject->count(
collection: 'memberships',
queries: $filterQueries,
max: 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.");
}
$memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId')));
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
];
$roles = $authorization->getRoles();
$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))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
$mfa = $user->getAttribute('mfa', false);
if ($mfa) {
$totp = TOTP::getAuthenticatorFromUser($user);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
}
}
$membership->setAttribute('mfa', $mfa);
}
if ($membershipsPrivacy['userName']) {
$membership->setAttribute('userName', $user->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
$membership->setAttribute('userEmail', $user->getAttribute('email'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
return $membership;
}, $memberships);
$response->dynamic(new Document([
'memberships' => $memberships,
'total' => $total,
]), Response::MODEL_MEMBERSHIP_LIST);
}
}
@@ -0,0 +1,71 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Preferences;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getTeamPreferences';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/prefs')
->desc('Get team preferences')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'getPrefs',
description: '/docs/references/teams/get-team-prefs.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PREFERENCES,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(string $teamId, Response $response, Database $dbForProject)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$prefs = $team->getAttribute('prefs', []);
try {
$prefs = new Document($prefs);
} catch (StructureException $e) {
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage());
}
$response->dynamic($prefs, Response::MODEL_PREFERENCES);
}
}
@@ -0,0 +1,83 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Preferences;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Assoc;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamPreferences';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/teams/:teamId/prefs')
->desc('Update team preferences')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].update.prefs')
->label('scope', 'teams.write')
->label('audits.event', 'team.update')
->label('audits.resource', 'team/{response.$id}')
->label('audits.userId', '{response.$id}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'updatePrefs',
description: '/docs/references/teams/update-team-prefs.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PREFERENCES,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, array $prefs, Response $response, Database $dbForProject, Event $queueForEvents)
{
try {
$prefs = new Document($prefs);
} catch (StructureException $e) {
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage());
}
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$team = $dbForProject->updateDocument('teams', $team->getId(), new Document([
'prefs' => $prefs->getArrayCopy()
]));
$queueForEvents->setParam('teamId', $team->getId());
$response->dynamic($prefs, Response::MODEL_PREFERENCES);
}
}
@@ -0,0 +1,138 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName()
{
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')
->label('audits.event', 'team.create')
->label('audits.resource', 'team/{response.$id}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'create',
description: '/docs/references/teams/create-team.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_TEAM,
)
]
))
->param('teamId', '', new CustomId(), 'Team 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.')
->param('name', null, new Text(128), 'Team name. Max length: 128 chars.')
->param('roles', ['owner'], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', true)
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$teamId = $teamId == 'unique()' ? ID::unique() : $teamId;
try {
$team = $authorization->skip(fn () => $dbForProject->createDocument('teams', new Document([
'$id' => $teamId,
'$permissions' => [
Permission::read(Role::team($teamId)),
Permission::update(Role::team($teamId, 'owner')),
Permission::delete(Role::team($teamId, 'owner')),
],
'labels' => [],
'name' => $name,
'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1,
'prefs' => new \stdClass(),
'search' => implode(' ', [$teamId, $name]),
])));
} catch (Duplicate $th) {
throw new Exception(Exception::TEAM_ALREADY_EXISTS);
}
if (!$isPrivilegedUser && !$isAppUser) { // Don't add user on server mode
if (!\in_array('owner', $roles)) {
$roles[] = 'owner';
}
$membershipId = ID::unique();
$membership = new Document([
'$id' => $membershipId,
'$permissions' => [
Permission::read(Role::user($user->getId())),
Permission::read(Role::team($team->getId())),
Permission::update(Role::user($user->getId())),
Permission::update(Role::team($team->getId(), 'owner')),
Permission::delete(Role::user($user->getId())),
Permission::delete(Role::team($team->getId(), 'owner')),
],
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'teamId' => $team->getId(),
'teamInternalId' => $team->getSequence(),
'roles' => $roles,
'invited' => DateTime::now(),
'joined' => DateTime::now(),
'confirm' => true,
'secret' => '',
'search' => implode(' ', [$membershipId, $user->getId()])
]);
$membership = $dbForProject->createDocument('memberships', $membership);
$dbForProject->purgeCachedDocument('users', $user->getId());
}
$queueForEvents->setParam('teamId', $team->getId());
if (!empty($user->getId())) {
$queueForEvents->setParam('userId', $user->getId());
}
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($team, Response::MODEL_TEAM);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Workers\Deletes;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteTeam';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/teams/:teamId')
->desc('Delete team')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].delete')
->label('scope', 'teams.write')
->label('audits.event', 'team.delete')
->label('audits.resource', 'team/{request.teamId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'delete',
description: '/docs/references/teams/delete-team.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('teamId', '', new UID(), 'Team ID.')
->inject('response')
->inject('getProjectDB')
->inject('dbForProject')
->inject('queueForDeletes')
->inject('queueForEvents')
->inject('project')
->callback($this->action(...));
}
public function action(string $teamId, Response $response, callable $getProjectDB, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Document $project)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if (!$dbForProject->deleteDocument('teams', $teamId)) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove team from DB');
}
// Sync delete
$deletes = new Deletes();
$deletes->deleteMemberships($getProjectDB, $team, $project);
// Async delete
if ($project->getId() === 'console') {
$queueForDeletes
->setType(DELETE_TYPE_TEAM_PROJECTS)
->setDocument($team)
->trigger();
}
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
->setDocument($team);
$queueForEvents
->setParam('teamId', $team->getId())
->setPayload($response->output($team, Response::MODEL_TEAM))
;
$response->noContent();
}
}
@@ -0,0 +1,61 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getTeam';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId')
->desc('Get team')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'get',
description: '/docs/references/teams/get-team.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_TEAM,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(string $teamId, Response $response, Database $dbForProject)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$response->dynamic($team, Response::MODEL_TEAM);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams\Name;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamName';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/teams/:teamId')
->desc('Update name')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].update')
->label('scope', 'teams.write')
->label('audits.event', 'team.update')
->label('audits.resource', 'team/{response.$id}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'updateName',
description: '/docs/references/teams/update-team-name.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_TEAM,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('name', null, new Text(128), 'New team name. Max length: 128 chars.')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $name, Response $response, Database $dbForProject, Event $queueForEvents)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$team
->setAttribute('name', $name)
->setAttribute('search', implode(' ', [$teamId, $name]));
$team = $dbForProject->updateDocument('teams', $team->getId(), $team);
$queueForEvents->setParam('teamId', $team->getId());
$response->dynamic($team, Response::MODEL_TEAM);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Teams;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listTeams';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams')
->desc('List teams')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'list',
description: '/docs/references/teams/list-teams.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_TEAM_LIST,
)
]
))
->param('queries', [], new Teams(), '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(', ', Teams::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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('dbForProject')
->callback($this->action(...));
}
public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject)
{
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
$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());
}
$teamId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('teams', $teamId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Team '{$teamId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$results = $dbForProject->find('teams', $queries);
$total = $includeTotal ? $dbForProject->count('teams', $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([
'teams' => $results,
'total' => $total,
]), Response::MODEL_TEAM_LIST);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Teams;
use Appwrite\Platform\Modules\Teams\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,49 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Services;
use Appwrite\Platform\Modules\Teams\Http\Logs\XList as ListLogs;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Create as CreateMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Delete as DeleteMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Get as GetMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Status\Update as UpdateMembershipStatus;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Update as UpdateMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\XList as ListMemberships;
use Appwrite\Platform\Modules\Teams\Http\Preferences\Get as GetPreferences;
use Appwrite\Platform\Modules\Teams\Http\Preferences\Update as UpdatePreferences;
use Appwrite\Platform\Modules\Teams\Http\Teams\Create as CreateTeam;
use Appwrite\Platform\Modules\Teams\Http\Teams\Delete as DeleteTeam;
use Appwrite\Platform\Modules\Teams\Http\Teams\Get as GetTeam;
use Appwrite\Platform\Modules\Teams\Http\Teams\Name\Update as UpdateTeamName;
use Appwrite\Platform\Modules\Teams\Http\Teams\XList as ListTeams;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
// Teams
$this->addAction(CreateTeam::getName(), new CreateTeam());
$this->addAction(GetTeam::getName(), new GetTeam());
$this->addAction(ListTeams::getName(), new ListTeams());
$this->addAction(DeleteTeam::getName(), new DeleteTeam());
$this->addAction(UpdateTeamName::getName(), new UpdateTeamName());
// Preferences
$this->addAction(GetPreferences::getName(), new GetPreferences());
$this->addAction(UpdatePreferences::getName(), new UpdatePreferences());
// Memberships
$this->addAction(CreateMembership::getName(), new CreateMembership());
$this->addAction(GetMembership::getName(), new GetMembership());
$this->addAction(ListMemberships::getName(), new ListMemberships());
$this->addAction(UpdateMembership::getName(), new UpdateMembership());
$this->addAction(DeleteMembership::getName(), new DeleteMembership());
$this->addAction(UpdateMembershipStatus::getName(), new UpdateMembershipStatus());
// Logs
$this->addAction(ListLogs::getName(), new ListLogs());
}
}
@@ -4,13 +4,12 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Callback;
use Appwrite\Auth\OAuth2\Github as OAuth2Github;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Permission as AppwritePermission;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -21,6 +20,7 @@ use Utopia\VCS\Adapter\Git\GitHub;
class Get extends Action
{
use HTTP;
use AppwritePermission;
public static function getName()
{
@@ -132,13 +132,7 @@ class Get extends Action
$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')),
],
'$permissions' => $this->getPermissions($teamId, $projectId),
'providerInstallationId' => $providerInstallationId,
'projectId' => $projectId,
'projectInternalId' => $projectInternalId,
@@ -152,6 +152,8 @@ class Create extends Action
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
$repository['providerInstallationId'] = $installation->getAttribute('providerInstallationId', '');
$repository['authorized'] = true;
$response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY);
}
@@ -85,11 +85,23 @@ class Get extends Action
$repository = $github->getRepository($owner, $repositoryName);
$authorized = false;
try {
$installationRepository = $github->getInstallationRepository($repositoryName);
if (!empty($installationRepository)) {
$authorized = true;
}
} catch (RepositoryNotFound $e) {
$authorized = false;
}
$repository['id'] = \strval($repository['id']) ?? '';
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
$repository['defaultBranch'] = $repository['default_branch'] ?? '';
$repository['authorized'] = $authorized;
$repository['providerInstallationId'] = $providerInstallationId;
$response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY);
}
@@ -141,13 +141,15 @@ class XList extends Action
$page = ($offset / $limit) + 1;
$owner = $github->getOwnerName($providerInstallationId);
['items' => $repos, 'total' => $total] = $github->searchRepositories($owner, $page, $limit, $search);
['items' => $repos, 'total' => $total] = $github->searchRepositories($providerInstallationId, $owner, $page, $limit, $search);
$repos = \array_map(function ($repo) use ($installation) {
$repo['id'] = \strval($repo['id'] ?? '');
$repo['pushedAt'] = $repo['pushed_at'] ?? null;
$repo['provider'] = $installation->getAttribute('provider', '') ?? '';
$repo['organization'] = $installation->getAttribute('organization', '') ?? '';
$repo['providerInstallationId'] = $installation->getAttribute('providerInstallationId', '');
$repo['authorized'] = true;
return $repo;
}, $repos);
+10
View File
@@ -805,6 +805,16 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setExcludePaths([
'.github/workflows/**',
'.github/ISSUE_TEMPLATE/**',
'package.json',
'package-lock.json',
'install.sh',
'install.ps1',
'README.md',
'CHANGELOG.md',
'LICENSE',
'LICENSE.md',
'scoop/*.json',
'lib/constants.ts',
])
->setMaxDiffLines(500)
->setUserId('sdk-analyst');
+9 -4
View File
@@ -11,7 +11,6 @@ use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Response\Model\Execution;
use Exception;
use Executor\Executor;
use Utopia\Config\Config;
use Utopia\Console;
@@ -73,7 +72,10 @@ class Functions extends Action
$payload = $message->getPayload() ?? [];
if (empty($payload)) {
throw new Exception('Missing payload');
throw new AppwriteException(
AppwriteException::GENERAL_ARGUMENT_INVALID,
'Functions worker: missing payload in schedule execution'
);
}
$type = $payload['type'] ?? '';
@@ -392,7 +394,10 @@ class Functions extends Action
$runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []);
if (!\array_key_exists($function->getAttribute('runtime'), $runtimes)) {
throw new Exception('Runtime "' . $function->getAttribute('runtime', '') . '" is not supported');
throw new AppwriteException(
AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED,
\sprintf('Runtime "%s" is not supported', $function->getAttribute('runtime', '')),
);
}
$runtime = $runtimes[$function->getAttribute('runtime')];
@@ -640,7 +645,7 @@ class Functions extends Action
if (!empty($error)) {
throw new AppwriteException(
AppwriteException::GENERAL_SERVER_ERROR,
$error ?: 'Function execution failed with no error message',
'Function execution failed: ' . ($error ?: 'No error message provided'),
$errorCode
);
}
@@ -313,6 +313,8 @@ class Migrations extends Action
'files.write',
'functions.read',
'functions.write',
'sites.read',
'sites.write',
'tokens.read',
'tokens.write',
]
+32
View File
@@ -453,6 +453,38 @@ abstract class Format
break;
}
break;
case 'migrations':
switch ($method) {
case 'createAppwriteMigration':
case 'getAppwriteReport':
switch ($param) {
case 'resources':
return 'AppwriteMigrationResource';
}
break;
case 'createFirebaseMigration':
case 'getFirebaseReport':
switch ($param) {
case 'resources':
return 'FirebaseMigrationResource';
}
break;
case 'createSupabaseMigration':
case 'getSupabaseReport':
switch ($param) {
case 'resources':
return 'SupabaseMigrationResource';
}
break;
case 'createNHostMigration':
case 'getNHostReport':
switch ($param) {
case 'resources':
return 'NHostMigrationResource';
}
break;
}
break;
case 'project':
switch ($method) {
case 'getUsage':
@@ -53,6 +53,12 @@ class MigrationReport extends Model
'default' => 0,
'example' => 20,
])
->addRule(Resource::TYPE_SITE, [
'type' => self::TYPE_INTEGER,
'description' => 'Number of sites to be migrated.',
'default' => 0,
'example' => 5,
])
->addRule('size', [
'type' => self::TYPE_INTEGER,
'description' => 'Size of files to be migrated in mb.',
@@ -47,6 +47,18 @@ class ProviderRepository extends Model
'default' => '',
'example' => 'main',
])
->addRule('providerInstallationId', [
'type' => self::TYPE_STRING,
'description' => 'VCS (Version Control System) installation ID.',
'default' => '',
'example' => '108104697',
])
->addRule('authorized', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Is VCS (Version Control System) repository authorized for the installation?',
'default' => false,
'example' => true,
])
->addRule('pushedAt', [
'type' => self::TYPE_DATETIME,
'description' => 'Last commit date in ISO 8601 format.',
@@ -3267,6 +3267,205 @@ trait DatabasesBase
], $this->getHeaders()));
}
public function testListDocumentsWithCache(): void
{
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
$docIds = $data['documentIds'];
// Filter to setup documents only, since other tests may have created additional docs in this collection.
$baseQueries = [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', 'releaseYear', '$id'])->toString(),
Query::orderAsc('releaseYear')->toString(),
];
// 1. Using cache with select queries, first request should miss 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' => $baseQueries,
'ttl' => 30,
]);
$this->assertEquals(200, $documents1['headers']['status-code']);
$this->assertEquals(3, $documents1['body']['total']);
$this->assertCount(3, $documents1['body'][$this->getRecordResource()]);
$this->assertEquals(1944, $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertEquals(2017, $documents1['body'][$this->getRecordResource()][1]['releaseYear']);
$this->assertEquals(2019, $documents1['body'][$this->getRecordResource()][2]['releaseYear']);
$this->assertArrayHasKey('title', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('releaseYear', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('$id', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
// 2. Using cache with same select queries, should return cached results.
$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' => $baseQueries,
'ttl' => 30,
]);
$this->assertEquals(200, $documents2['headers']['status-code']);
$this->assertEquals(3, $documents2['body']['total']);
$this->assertCount(3, $documents2['body'][$this->getRecordResource()]);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['releaseYear'], $documents2['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
// 3. Using cache with same select queries but total is false, should return cached results just for documents.
$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' => $baseQueries,
'ttl' => 30,
'total' => false,
]);
$this->assertEquals(200, $documents3['headers']['status-code']);
$this->assertCount(3, $documents3['body'][$this->getRecordResource()]);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['$id'], $documents1['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['releaseYear'], $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertEquals(0, $documents3['body']['total']);
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
$this->assertEquals('hit', $documents3['headers']['x-appwrite-cache']);
// 4. Using cache with different select queries, should miss 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' => [
Query::equal('$id', $docIds)->toString(),
Query::select(['title'])->toString(),
Query::orderAsc('releaseYear')->toString(),
],
'ttl' => 10,
]);
$this->assertEquals(200, $documents4['headers']['status-code']);
$this->assertEquals(3, $documents4['body']['total']);
$this->assertCount(3, $documents4['body'][$this->getRecordResource()]);
$this->assertEquals($documents4['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents4['body'][$this->getRecordResource()][1]['title'], $documents1['body'][$this->getRecordResource()][1]['title']);
$this->assertEquals($documents4['body'][$this->getRecordResource()][2]['title'], $documents1['body'][$this->getRecordResource()][2]['title']);
$this->assertArrayHasKey('x-appwrite-cache', $documents4['headers']);
$this->assertEquals('miss', $documents4['headers']['x-appwrite-cache']);
// 5. Not using cache at all
$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' => [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', 'releaseYear', '$id'])->toString(),
Query::orderAsc('releaseYear')->toString(),
],
]);
$this->assertEquals(200, $documents5['headers']['status-code']);
$this->assertCount(3, $documents5['body'][$this->getRecordResource()]);
$this->assertEquals(1944, $documents5['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertArrayNotHasKey('x-appwrite-cache', $documents5['headers']);
sleep(10);
// 6. Using cache with same select queries but passed ttl time, should miss cache.
$documents6 = $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' => $baseQueries,
'ttl' => 10,
]);
$this->assertEquals(200, $documents6['headers']['status-code']);
$this->assertCount(3, $documents6['body'][$this->getRecordResource()]);
$this->assertArrayHasKey('title', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('releaseYear', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('$id', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('x-appwrite-cache', $documents6['headers']);
$this->assertEquals('miss', $documents6['headers']['x-appwrite-cache']);
}
public function testListDocumentsCacheBustedByAttributeChange(): void
{
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
$docIds = $data['documentIds'];
// Use different select queries from testListDocumentsWithCache to avoid cache key collision.
$queries = [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', '$id'])->toString(),
Query::orderAsc('$createdAt')->toString(),
];
// 1. First request should miss 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->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
// 2. Same request should hit 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->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
// 3. Add a new attribute to the collection, which updates the collection's $updatedAt.
$attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'cacheTestAttr',
'size' => 64,
'required' => false,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for the attribute to be ready
$this->waitForAttribute($databaseId, $data['moviesId'], 'cacheTestAttr');
// 4. Same request should now miss cache because collection $updatedAt changed.
$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->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
$this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']);
}
public function testGetDocument(): void
{
$data = $this->getDocumentsList();
@@ -566,6 +566,21 @@ class DatabasesStringTypesTest extends Scope
$databaseId = $data['databaseId'];
$collectionId = $data['collectionId'];
$encryptedVarchar = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/varchar', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'key' => 'varchar_encrypted_collection',
'size' => 256,
'required' => false,
'encrypt' => true,
]);
$this->assertEquals(202, $encryptedVarchar['headers']['status-code']);
$this->waitForAllAttributes($databaseId, $collectionId);
$response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@@ -581,6 +596,27 @@ class DatabasesStringTypesTest extends Scope
$this->assertContains('text', $types);
$this->assertContains('mediumtext', $types);
$this->assertContains('longtext', $types);
// Ensure encrypt flag is present and boolean for all string-like types
$attributesByKey = [];
foreach ($attributes as $attribute) {
$attributesByKey[$attribute['key']] = $attribute;
}
$this->assertArrayHasKey('varchar_field', $attributesByKey);
$this->assertFalse($attributesByKey['varchar_field']['encrypt']);
$this->assertArrayHasKey('text_field', $attributesByKey);
$this->assertFalse($attributesByKey['text_field']['encrypt']);
$this->assertArrayHasKey('mediumtext_field', $attributesByKey);
$this->assertFalse($attributesByKey['mediumtext_field']['encrypt']);
$this->assertArrayHasKey('longtext_field', $attributesByKey);
$this->assertFalse($attributesByKey['longtext_field']['encrypt']);
$this->assertArrayHasKey('varchar_encrypted_collection', $attributesByKey);
$this->assertTrue($attributesByKey['varchar_encrypted_collection']['encrypt']);
}
public function testUpdateVarcharAttribute(): void
@@ -7,6 +7,7 @@ use Tests\E2E\Client;
use Tests\E2E\General\UsageTest;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Services\Functions\FunctionsBase;
use Utopia\Console;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
@@ -1017,6 +1018,158 @@ trait MigrationsBase
]);
}
/**
* Sites
*/
public function testAppwriteMigrationSite(): void
{
$site = $this->client->call(Client::METHOD_POST, '/sites', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'siteId' => ID::unique(),
'name' => 'Test Site',
'framework' => 'other',
'buildRuntime' => 'node-22',
'adapter' => 'static',
'outputDirectory' => './',
]);
$this->assertEquals(201, $site['headers']['status-code'], 'Create site failed: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
$this->assertNotEmpty($site['body']['$id']);
$siteId = $site['body']['$id'];
// Create deployment
$deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'code' => $this->packageSite('static'),
'activate' => true,
]);
$this->assertEquals(202, $deployment['headers']['status-code']);
$this->assertNotEmpty($deployment['body']['$id']);
$deploymentId = $deployment['body']['$id'];
// Wait for deployment to be ready
$this->assertEventually(function () use ($siteId, $deploymentId) {
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [
'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('ready', $response['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($response['body'], JSON_PRETTY_PRINT));
}, 300000, 500);
// Create environment variable
$variable = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/variables', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'key' => 'TEST_VAR',
'value' => 'test_value',
]);
$this->assertEquals(201, $variable['headers']['status-code']);
// Perform migration
$result = $this->performMigrationSync([
'resources' => [
Resource::TYPE_SITE,
Resource::TYPE_SITE_DEPLOYMENT,
Resource::TYPE_SITE_VARIABLE,
],
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
]);
$this->assertEquals('completed', $result['status']);
$this->assertEquals([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE], $result['resources']);
foreach ([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE] as $resource) {
$this->assertArrayHasKey($resource, $result['statusCounters']);
$this->assertEquals(0, $result['statusCounters'][$resource]['error']);
$this->assertEquals(0, $result['statusCounters'][$resource]['pending']);
$this->assertEquals(1, $result['statusCounters'][$resource]['success']);
$this->assertEquals(0, $result['statusCounters'][$resource]['processing']);
$this->assertEquals(0, $result['statusCounters'][$resource]['warning']);
}
// Verify site in destination
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$this->assertEquals($siteId, $response['body']['$id']);
$this->assertEquals('Test Site', $response['body']['name']);
$this->assertEquals('node-22', $response['body']['buildRuntime']);
$this->assertEquals('other', $response['body']['framework']);
$this->assertEquals('static', $response['body']['adapter']);
// Verify deployment in destination
$this->assertEventually(function () use ($siteId) {
$deployments = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $deployments['headers']['status-code']);
$this->assertNotEmpty($deployments['body']);
$this->assertEquals(1, $deployments['body']['total']);
$this->assertEquals('ready', $deployments['body']['deployments'][0]['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployments['body']['deployments'][0], JSON_PRETTY_PRINT));
}, 100000, 500);
// Verify variable in destination
$variables = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/variables', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $variables['headers']['status-code']);
$this->assertEquals(1, $variables['body']['total']);
$this->assertEquals('TEST_VAR', $variables['body']['variables'][0]['key']);
// Cleanup
$this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
}
private function packageSite(string $site): CURLFile
{
$stdout = '';
$stderr = '';
$folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site";
$tarPath = "$folderPath/code.tar.gz";
Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr);
return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
}
/**
* Import documents from a CSV file.
*/
@@ -131,4 +131,23 @@ trait RealtimeBase
$this->expectException(ConnectionException::class); // Check if server disconnected client
$client->close();
}
public function testConnectionRegionCheck(): void
{
/**
* Test for SUCCESS
* A project whose region matches the server region should connect successfully.
*/
$client = $this->getWebsocket(['documents']);
$response = json_decode($client->receive(), true);
$this->assertArrayHasKey('type', $response);
$this->assertArrayHasKey('data', $response);
$this->assertEquals('connected', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('channels', $response['data']);
$this->assertContains('documents', $response['data']['channels']);
$client->close();
}
}
@@ -123,6 +123,8 @@ class RealtimeCustomClientTest extends Scope
$this->assertNotEmpty($response['data']);
$this->assertNotEmpty($response['data']['user']);
$this->assertCount(16, $response['data']['channels']);
$this->assertIsList($response['data']['channels']);
$this->assertTrue(array_is_list($response['data']['channels']));
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertContains('files', $response['data']['channels']);
@@ -818,7 +820,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -863,7 +865,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -919,7 +921,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -975,7 +977,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -1007,7 +1009,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -1056,7 +1058,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -1084,7 +1086,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -1112,7 +1114,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -1149,7 +1151,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -1178,7 +1180,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -1207,7 +1209,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -1254,7 +1256,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -1433,7 +1435,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']);
@@ -1464,7 +1466,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']);
@@ -1514,7 +1516,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
@@ -1568,7 +1570,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
@@ -1621,7 +1623,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
@@ -1648,7 +1650,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
@@ -1687,7 +1689,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']);
@@ -1718,7 +1720,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']);
@@ -1771,7 +1773,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -1809,7 +1811,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -1951,7 +1953,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -1990,7 +1992,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -2040,7 +2042,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(6, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -2559,6 +2561,512 @@ class RealtimeCustomClientTest extends Scope
$client->close();
}
public function testChannelsTablesDB()
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
/**
* Create a shared TablesDB database using the /tablesdb API.
* This database will then be accessed via both /databases and /tablesdb routes.
*/
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'databaseId' => ID::unique(),
'name' => 'TablesDB Cross API Realtime DB',
]);
$databaseId = $database['body']['$id'];
$this->assertEquals(201, $database['headers']['status-code']);
/**
* Legacy collection in the shared database (/databases API).
*/
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'collectionId' => ID::unique(),
'name' => 'Legacy Actors',
'permissions' => [
Permission::create(Role::user($user['$id'])),
],
'documentSecurity' => true,
]);
$collectionId = $collection['body']['$id'];
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $collectionId) {
$attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals('available', $attribute['body']['status']);
}, 30000, 250);
/**
* TablesDB table in the same database (/tablesdb API).
*/
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'tableId' => ID::unique(),
'name' => 'Actors',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$tableId = $table['body']['$id'];
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $column['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $tableId) {
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()));
$this->assertEquals(200, $column['headers']['status-code']);
$this->assertEquals('available', $column['body']['status']);
}, 120000, 500);
/**
* Two different clients subscribing via legacy (documents/collections)
* and new (rows/tables) channels.
*/
$clientLegacy = $this->getWebsocket(['documents', 'collections'], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
]);
$response = json_decode($clientLegacy->receive(), true);
$this->assertArrayHasKey('type', $response);
$this->assertArrayHasKey('data', $response);
$this->assertEquals('connected', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertContains('documents', $response['data']['channels']);
$clientTables = $this->getWebsocket(['rows', 'tables'], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
]);
$response = json_decode($clientTables->receive(), true);
$this->assertArrayHasKey('type', $response);
$this->assertArrayHasKey('data', $response);
$this->assertEquals('connected', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertContains('rows', $response['data']['channels']);
/**
* 1) Operation via legacy /databases API (document create).
* Both clients should receive an event that includes both document-
* style and row-style channels on the shared database.
*/
$documentId = ID::unique();
$document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'documentId' => $documentId,
'data' => [
'name' => 'Legacy Chris Evans',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $document['headers']['status-code']);
$legacyEventForLegacyClient = json_decode($clientLegacy->receive(), true);
$legacyEventForTablesClient = json_decode($clientTables->receive(), true);
foreach ([$legacyEventForLegacyClient, $legacyEventForTablesClient] as $event) {
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
// Legacy-style channels
$this->assertContains('documents', $channels);
$this->assertContains("databases.{$databaseId}.collections.{$collectionId}.documents", $channels);
$this->assertContains("databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}", $channels);
// New rows-style channels mirrored for legacy API
$this->assertContains('rows', $channels);
$this->assertContains("databases.{$databaseId}.tables.{$collectionId}.rows", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$collectionId}.rows.{$documentId}", $channels);
// TablesDB-prefixed channels should also be present for a tablesdb database
$this->assertContains("tablesdb.{$databaseId}.tables.{$collectionId}.rows", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$collectionId}.rows.{$documentId}", $channels);
}
/**
* 2) Operation via /tablesdb API (row create).
* Both clients should again receive an event that now also includes
* the tablesdb-prefixed channels alongside the databases-prefixed ones.
*/
$rowId = ID::unique();
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'rowId' => $rowId,
'data' => [
'name' => 'Chris Evans',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $row['headers']['status-code']);
$tablesEventForLegacyClient = json_decode($clientLegacy->receive(), true);
$tablesEventForTablesClient = json_decode($clientTables->receive(), true);
foreach ([$tablesEventForLegacyClient, $tablesEventForTablesClient] as $event) {
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
// Core tablesdb row channels
$this->assertContains('rows', $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
// Collections/legacy-style compatibility channels
$this->assertContains('documents', $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents", $channels);
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents.{$rowId}", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.create", $event['data']['events']);
$this->assertNotEmpty($event['data']['payload']);
$this->assertEquals('Chris Evans', $event['data']['payload']['name']);
}
/**
* 3) Legacy database accessed via /tablesdb routes.
* A database created via /databases but operated on via /tablesdb
* should also expose both legacy and tablesdb-prefixed channels.
*/
$legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'databaseId' => ID::unique(),
'name' => 'Legacy DB via TablesDB Route',
]);
$this->assertEquals(201, $legacyDatabase['headers']['status-code']);
$legacyDatabaseId = $legacyDatabase['body']['$id'];
$legacyTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'tableId' => ID::unique(),
'name' => 'Legacy Actors',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$legacyTableId = $legacyTable['body']['$id'];
$legacyColumn = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $legacyColumn['headers']['status-code']);
$this->assertEventually(function () use ($legacyDatabaseId, $legacyTableId) {
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/columns/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()));
$this->assertEquals(200, $column['headers']['status-code']);
$this->assertEquals('available', $column['body']['status']);
}, 120000, 500);
$legacyRowId = ID::unique();
$legacyRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'rowId' => $legacyRowId,
'data' => [
'name' => 'Legacy Tables Route',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $legacyRow['headers']['status-code']);
$legacyTablesEventForLegacyClient = json_decode($clientLegacy->receive(), true);
$legacyTablesEventForTablesClient = json_decode($clientTables->receive(), true);
foreach ([$legacyTablesEventForLegacyClient, $legacyTablesEventForTablesClient] as $event) {
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
$events = $event['data']['events'];
$this->assertIsList($channels);
$this->assertIsList($events);
// Core tablesdb row channels for legacy db accessed via tablesdb
$this->assertContains('rows', $channels);
$this->assertContains("tablesdb.{$legacyDatabaseId}.tables.{$legacyTableId}.rows", $channels);
$this->assertContains("tablesdb.{$legacyDatabaseId}.tables.{$legacyTableId}.rows.{$legacyRowId}", $channels);
// Legacy compatibility channels must also exist
$this->assertContains('documents', $channels);
$this->assertContains("databases.{$legacyDatabaseId}.tables.{$legacyTableId}.rows", $channels);
$this->assertContains("databases.{$legacyDatabaseId}.tables.{$legacyTableId}.rows.{$legacyRowId}", $channels);
$this->assertContains("databases.{$legacyDatabaseId}.collections.{$legacyTableId}.documents", $channels);
$this->assertContains("databases.{$legacyDatabaseId}.collections.{$legacyTableId}.documents.{$legacyRowId}", $channels);
}
$clientLegacy->close();
$clientTables->close();
}
public function testChannelTablesDBRowUpdate()
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
/**
* Create a tablesdb database + table + column + row.
*/
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'Row Update DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'tableId' => ID::unique(),
'name' => 'Assembly',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$tableId = $table['body']['$id'];
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $column['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $tableId) {
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()));
$this->assertEquals(200, $column['headers']['status-code']);
$this->assertEquals('available', $column['body']['status']);
}, 120000, 500);
// Seed a row so we can listen to its update
$rowId = ID::unique();
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'rowId' => $rowId,
'data' => [
'name' => 'Initial Name',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $row['headers']['status-code']);
/**
* Subscribe to a specific row channel using both legacy and tablesdb-style prefixes.
* This mimics a client subscribing to a concrete "resource" channel and
* expecting a single event list for updates.
*/
$client = $this->getWebsocket([
"databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}",
"tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}",
], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
]);
$response = json_decode($client->receive(), true);
$this->assertArrayHasKey('type', $response);
$this->assertArrayHasKey('data', $response);
$this->assertEquals('connected', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertIsList($response['data']['channels']);
/**
* Trigger a row update via the dedicated /tablesdb row update endpoint.
* Event label: databases.[databaseId].tables.[tableId].rows.[rowId].update
* Our Event + Realtime logic should enrich this to include:
* - databases.{dbId}.tables.{tableId}.rows.{rowId}.update
* - tablesdb.{dbId}.tables.{tableId}.rows.{rowId}.update
*/
$update = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'data' => [
'name' => 'Updated Name',
],
]);
$this->assertEquals(200, $update['headers']['status-code']);
$event = json_decode($client->receive(), true);
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
$events = $event['data']['events'];
// Ensure channels and events are list-type arrays
$this->assertIsList($channels);
$this->assertIsList($events);
// Legacy + tablesdb row channels must be present
$this->assertContains('rows', $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
// Both databases.* and tablesdb.* update events should be emitted
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update", $events);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update", $events);
$this->assertNotEmpty($event['data']['payload']);
$this->assertEquals('Updated Name', $event['data']['payload']['name']);
$client->close();
}
public function testChannelDatabaseTransaction()
{
$user = $this->getUser();