From a57d970840bb37119e54b2645126428f8c48c2ae Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 25 Feb 2026 18:58:33 +0530 Subject: [PATCH 01/12] Add support for tablesdb in event generation and realtime channels --- src/Appwrite/Event/Event.php | 72 +++++++++-- src/Appwrite/Event/Realtime.php | 2 +- src/Appwrite/Messaging/Adapter/Realtime.php | 76 ++++++++++- .../Realtime/RealtimeCustomClientTest.php | 120 ++++++++++++++++++ 4 files changed, 255 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index 6f00d0cd0e..53c071573e 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -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,32 @@ class Event $events = $pairedEvents; } + // mirrored events can have duplicates in case of smaller events + return 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; } /** diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 6cb51ffa14..419863191e 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -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()); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index e61cfdc116..29ea0ff1b6 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -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,26 @@ class Realtime extends MessagingAdapter $tableId = $payload->getAttribute('$tableId', ''); $collectionId = $payload->getAttribute('$collectionId', ''); $resourceId = $tableId ?: $collectionId; + $channels = []; + // backward compat(tablesdb will have databases channels + tablesdb prefixed channels) + if ($parts[0] === 'databases' || $parts[0] === 'tablesdb') { + $prefix = 'databases'; - $channels[] = 'rows'; - $channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows'; - $channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows.' . $payload->getId(); + $channels = self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), $prefix); - $channels[] = 'documents'; - $channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents'; - $channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents.' . $payload->getId(); + $channels = array_unique([ + ...$channels, + ...self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), $prefix) + ]); + } + + // prefixed channels -> tablesdb + if ($parts[0] !== 'databases') { + $channels = array_unique([ + ...$channels, + ...self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId()), + ]); + } $roles = $collection->getAttribute('documentSecurity', false) ? \array_merge($collection->getRead(), $payload->getRead()) @@ -572,4 +585,55 @@ 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; + } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 8b31af73d3..ddd620f27b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -2559,6 +2559,126 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + public function testChannelsTablesDB() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $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' => 'TablesDB Realtime DB', + ]); + + $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' => '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); + + $client = $this->getWebsocket(['documents', 'collections'], [ + '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->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + + $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']); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + + // Core channels for tablesdb row events + $this->assertContains('rows', $response['data']['channels']); + + $this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $response['data']['channels']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $response['data']['channels']); + + // Collections-style compatibility channels + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents.{$rowId}", $response['data']['channels']); + + // Primary event should still be present + $this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.create", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Chris Evans', $response['data']['payload']['name']); + + $client->close(); + } + public function testChannelDatabaseTransaction() { $user = $this->getUser(); From 8c579cf88d3352636b61645f879dac0baac05180 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 25 Feb 2026 19:29:03 +0530 Subject: [PATCH 02/12] updated string filtering in the filters --- app/init/database/filters.php | 4 ++++ .../Legacy/DatabasesStringTypesTest.php | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/init/database/filters.php b/app/init/database/filters.php index aeb319299c..c2ba091529 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -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; diff --git a/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php b/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php index 0ee1f2d691..55293c337e 100644 --- a/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php +++ b/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php @@ -565,7 +565,6 @@ class DatabasesStringTypesTest extends Scope $data = $this->setupDatabaseAndCollection(); $databaseId = $data['databaseId']; $collectionId = $data['collectionId']; - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -581,6 +580,24 @@ 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']); } public function testUpdateVarcharAttribute(): void From ca8d613614f40d9f359538821b87da2c405732b1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 25 Feb 2026 19:46:09 +0530 Subject: [PATCH 03/12] updated test --- .../Legacy/DatabasesStringTypesTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php b/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php index 55293c337e..f68f0b909e 100644 --- a/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php +++ b/tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php @@ -565,6 +565,22 @@ class DatabasesStringTypesTest extends Scope $data = $this->setupDatabaseAndCollection(); $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'], @@ -598,6 +614,9 @@ class DatabasesStringTypesTest extends Scope $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 From 159da8ba3106a077246b9ebe26dfcef480145d77 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Wed, 25 Feb 2026 23:58:22 +0100 Subject: [PATCH 04/12] Fix 500 errors where we don't report duplication properly --- app/config/errors.php | 10 ++++ app/controllers/api/messaging.php | 27 +++++++--- src/Appwrite/Extend/Exception.php | 2 + .../Modules/Sites/Http/Sites/Create.php | 19 ++++--- .../Modules/Sites/Http/Sites/Update.php | 8 ++- tests/unit/Migration/MigrationTest.php | 51 +++++++++++++++++++ 6 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 tests/unit/Migration/MigrationTest.php diff --git a/app/config/errors.php b/app/config/errors.php index a9ca0f79dd..bf0f4461f6 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -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).', diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index d0049c1397..a52ec70b12 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -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: diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index e911faf77e..95a9c6ddac 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -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'; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index 70781f41af..1da9196980 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -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()); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 642d6bbe91..895e7ae3ef 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -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(); } diff --git a/tests/unit/Migration/MigrationTest.php b/tests/unit/Migration/MigrationTest.php new file mode 100644 index 0000000000..bb6c49d2fc --- /dev/null +++ b/tests/unit/Migration/MigrationTest.php @@ -0,0 +1,51 @@ +method->invokeArgs($this->migration, [ + $this->method->invokeArgs($this->migration, [$document]) + ]); + } + + /** + * Check versions array integrity. + */ + public function testMigrationVersions(): void + { + require_once __DIR__ . '/../../../app/init.php'; + + foreach (Migration::$versions as $class) { + $this->assertTrue(class_exists('Appwrite\\Migration\\Version\\' . $class)); + } + + // Test if current version exists + // Only test official releases - skip if latest is release candidate + if (!(\str_contains(APP_VERSION_STABLE, 'RC'))) { + $this->assertArrayHasKey(APP_VERSION_STABLE, Migration::$versions); + } + } +} From b7e4f78c569cc26cad47a70ee2a23b9405ac509b Mon Sep 17 00:00:00 2001 From: eldadfux Date: Thu, 26 Feb 2026 00:00:10 +0100 Subject: [PATCH 05/12] fix --- tests/unit/Migration/MigrationTest.php | 51 -------------------------- 1 file changed, 51 deletions(-) delete mode 100644 tests/unit/Migration/MigrationTest.php diff --git a/tests/unit/Migration/MigrationTest.php b/tests/unit/Migration/MigrationTest.php deleted file mode 100644 index bb6c49d2fc..0000000000 --- a/tests/unit/Migration/MigrationTest.php +++ /dev/null @@ -1,51 +0,0 @@ -method->invokeArgs($this->migration, [ - $this->method->invokeArgs($this->migration, [$document]) - ]); - } - - /** - * Check versions array integrity. - */ - public function testMigrationVersions(): void - { - require_once __DIR__ . '/../../../app/init.php'; - - foreach (Migration::$versions as $class) { - $this->assertTrue(class_exists('Appwrite\\Migration\\Version\\' . $class)); - } - - // Test if current version exists - // Only test official releases - skip if latest is release candidate - if (!(\str_contains(APP_VERSION_STABLE, 'RC'))) { - $this->assertArrayHasKey(APP_VERSION_STABLE, Migration::$versions); - } - } -} From 88918906014d7b8119ec2e563a53a1763a5bc116 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 26 Feb 2026 09:22:33 +0530 Subject: [PATCH 06/12] fix: show timed-out executions as failed across API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Functions/Http/Executions/Delete.php | 9 ++++ .../Modules/Functions/Http/Executions/Get.php | 10 +++++ .../Functions/Http/Executions/XList.php | 44 +++++++++++++++++++ .../Platform/Modules/Sites/Http/Logs/Get.php | 10 +++++ .../Modules/Sites/Http/Logs/XList.php | 44 +++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php index d77a76fe14..4a04afa119 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php @@ -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); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index 1fa56ef6f7..70912cf58c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -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); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index 404b70b00f..dcc3f6ee9c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -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, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Logs/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Logs/Get.php index b10bc6babd..c482add2da 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Logs/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Logs/Get.php @@ -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 } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Logs/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Logs/XList.php index 8b5e38f3db..89fb3ee2e6 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Logs/XList.php @@ -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, From 76965252d81b07e503912e97246d235732484a2c Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:18:29 +0530 Subject: [PATCH 07/12] 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 --- app/config/services.php | 2 +- app/controllers/api/teams.php | 1514 ----------------- app/init/constants.php | 1 + src/Appwrite/Platform/Appwrite.php | 2 + .../Modules/Teams/Http/Logs/XList.php | 137 ++ .../Modules/Teams/Http/Memberships/Create.php | 434 +++++ .../Modules/Teams/Http/Memberships/Delete.php | 155 ++ .../Modules/Teams/Http/Memberships/Get.php | 119 ++ .../Teams/Http/Memberships/Status/Update.php | 212 +++ .../Modules/Teams/Http/Memberships/Update.php | 139 ++ .../Modules/Teams/Http/Memberships/XList.php | 179 ++ .../Modules/Teams/Http/Preferences/Get.php | 71 + .../Modules/Teams/Http/Preferences/Update.php | 83 + .../Modules/Teams/Http/Teams/Create.php | 138 ++ .../Modules/Teams/Http/Teams/Delete.php | 99 ++ .../Platform/Modules/Teams/Http/Teams/Get.php | 61 + .../Modules/Teams/Http/Teams/Name/Update.php | 76 + .../Modules/Teams/Http/Teams/XList.php | 104 ++ .../Platform/Modules/Teams/Module.php | 14 + .../Platform/Modules/Teams/Services/Http.php | 49 + 20 files changed, 2074 insertions(+), 1515 deletions(-) delete mode 100644 app/controllers/api/teams.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Preferences/Get.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Preferences/Update.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Teams/Get.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Http/Teams/XList.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Module.php create mode 100644 src/Appwrite/Platform/Modules/Teams/Services/Http.php diff --git a/app/config/services.php b/app/config/services.php index 5d503b0099..0bbba96032 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -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', diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php deleted file mode 100644 index 00aa717b13..0000000000 --- a/app/controllers/api/teams.php +++ /dev/null @@ -1,1514 +0,0 @@ -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', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), '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.', false, ['dbForProject']) - ->param('name', null, new Text(128), 'Team name. Max length: 128 chars.') - ->param('roles', ['owner'], fn (Database $dbForProject) => new ArrayList(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 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, ['dbForProject']) - ->inject('response') - ->inject('user') - ->inject('dbForProject') - ->inject('authorization') - ->inject('queueForEvents') - ->action(function (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); - }); - -Http::get('/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') - ->action(function (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); - }); - -Http::get('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->action(function (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); - }); - -Http::get('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->action(function (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); - }); - -Http::put('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('name', null, new Text(128), 'New team name. Max length: 128 chars.') - ->inject('requestTimestamp') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $teamId, string $name, ?\DateTime $requestTimestamp, 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); - }); - -Http::put('/v1/teams/:teamId/prefs') - ->desc('Update 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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (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); - }); - -Http::delete('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->inject('response') - ->inject('getProjectDB') - ->inject('dbForProject') - ->inject('queueForDeletes') - ->inject('queueForEvents') - ->inject('project') - ->action(function (string $teamId, Response $response, callable $getProjectDB, Database $dbForProject, Delete $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(); - }); - -Http::post('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('email', '', new EmailValidator(), 'Email of the new team member.', true) - ->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'ID of the user to be added to a team.', true, ['dbForProject']) - ->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 32 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project--`. 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') - ->action(function (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(__DIR__ . '/../../config/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(__DIR__ . '/../../config/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 - ); - }); - -Http::get('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->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') - ->action(function (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); - }); - -Http::get('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('membershipId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Membership ID.', false, ['dbForProject']) - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->inject('authorization') - ->action(function (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); - }); - -Http::patch('/v1/teams/:teamId/memberships/:membershipId') - ->desc('Update 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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('membershipId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Membership ID.', false, ['dbForProject']) - ->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 32 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project--`. 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') - ->action(function (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 - ); - }); - -Http::patch('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('membershipId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Membership ID.', false, ['dbForProject']) - ->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'User ID.', false, ['dbForProject']) - ->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') - ->action(function (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 - ); - }); - -Http::delete('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->param('membershipId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Membership ID.', false, ['dbForProject']) - ->inject('user') - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->inject('authorization') - ->inject('queueForEvents') - ->action(function (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); - } - - 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. */ - 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); - } catch (\Throwable $exception) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove membership from DB'); - } - - $dbForProject->purgeCachedDocument('users', $profile->getId()); - - 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(); - }); - -Http::get('/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', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Team ID.', false, ['dbForProject']) - ->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') - ->action(function (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); - }); diff --git a/app/init/constants.php b/app/init/constants.php index fe9af93436..c6a5bc853e 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -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 diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 9982b0bf1e..681e1038c3 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -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()); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php new file mode 100644 index 0000000000..486807d5f9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php @@ -0,0 +1,137 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php new file mode 100644 index 0000000000..282d78ea0c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -0,0 +1,434 @@ +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--`. 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 + ); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php new file mode 100644 index 0000000000..8b80997f5b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -0,0 +1,155 @@ +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); + } + + 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.'); + } + } + + $this->validate($profile, $team, $dbForProject); + + 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; + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php new file mode 100644 index 0000000000..9bfbd8528e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -0,0 +1,119 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php new file mode 100644 index 0000000000..eac516c6fe --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php @@ -0,0 +1,212 @@ +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 + ); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php new file mode 100644 index 0000000000..98f342cecd --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php @@ -0,0 +1,139 @@ +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--`. 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 + ); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php new file mode 100644 index 0000000000..ba59f48b43 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -0,0 +1,179 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Preferences/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Preferences/Get.php new file mode 100644 index 0000000000..043bc2c64a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Preferences/Get.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Preferences/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Preferences/Update.php new file mode 100644 index 0000000000..2ec5dc9c74 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Preferences/Update.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php new file mode 100644 index 0000000000..ae20017e76 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php @@ -0,0 +1,138 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php new file mode 100644 index 0000000000..0cb7c54a26 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php @@ -0,0 +1,99 @@ +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(); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Get.php new file mode 100644 index 0000000000..52a66edf56 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Get.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php new file mode 100644 index 0000000000..4b058c58e1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php @@ -0,0 +1,76 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/XList.php new file mode 100644 index 0000000000..2a69580845 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/XList.php @@ -0,0 +1,104 @@ +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); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Module.php b/src/Appwrite/Platform/Modules/Teams/Module.php new file mode 100644 index 0000000000..95a3fee0b3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Teams/Services/Http.php b/src/Appwrite/Platform/Modules/Teams/Services/Http.php new file mode 100644 index 0000000000..4ffdc5fd4b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Teams/Services/Http.php @@ -0,0 +1,49 @@ +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()); + } +} From 9b2143a2a57514663b4c911af127d699945f5a57 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Thu, 26 Feb 2026 07:44:35 +0100 Subject: [PATCH 08/12] Fixed cache duplication --- app/controllers/shared/api.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c018803c82..c85d9aea73 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -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,18 @@ 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 + } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { $cacheLog->setAttribute('accessedAt', $now); $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog)); From 79d219bf509913d48afc21a82a8241d5a0bcef29 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Thu, 26 Feb 2026 07:50:51 +0100 Subject: [PATCH 09/12] fix cache duplication --- app/controllers/shared/api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c85d9aea73..c6499ff9b6 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -941,6 +941,7 @@ Http::shutdown() ]))); } 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); From a76a42d2dc0c482729a16721e27d1c4ba7c81c1a Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:25:28 +0530 Subject: [PATCH 10/12] Change validation order in delete memberships API (#11410) --- .../Platform/Modules/Teams/Http/Memberships/Delete.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 8b80997f5b..56f5cd8cb5 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -84,6 +84,8 @@ class Delete extends Action 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 @@ -112,8 +114,6 @@ class Delete extends Action } } - $this->validate($profile, $team, $dbForProject); - try { $dbForProject->deleteDocument('memberships', $membership->getId()); } catch (AuthorizationException $exception) { From 75629464343bbd7a15b19ac130afcfbc716847c9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 26 Feb 2026 14:29:31 +0530 Subject: [PATCH 11/12] bump pools --- composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.lock b/composer.lock index 8dd039d908..7065be335c 100644 --- a/composer.lock +++ b/composer.lock @@ -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", @@ -9067,5 +9067,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From d7c8b9d43a320c7bbc55acec47e24582864e5c78 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Thu, 26 Feb 2026 10:24:46 +0100 Subject: [PATCH 12/12] Better error message when a function fail instead of general_unknown --- src/Appwrite/Platform/Workers/Functions.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 9f1f328fd6..0932aea335 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -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 ); }