From c0c053ff20294ba021cdfad3fb0d1653e9fbe2b3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 12:52:52 +0530 Subject: [PATCH 01/56] Enhance Realtime adapter with action channel support and tests - Introduced ACTION_ALL and SUPPORTED_ACTIONS constants for better action handling. - Updated channel subscription logic to support action suffixes. - Added tests for action channel parsing and filtering in MessagingTest. --- src/Appwrite/Messaging/Adapter/Realtime.php | 158 +++++++++++- tests/unit/Messaging/MessagingTest.php | 260 ++++++++++++++++++++ 2 files changed, 405 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 8fe7342ec2..a03ccecfd9 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,6 +14,18 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { + /** + * Action suffix that means "all actions" — i.e. no action filter on this subscription. + */ + public const ACTION_ALL = '*'; + + /** + * Action suffixes recognized in channel names. A channel like `documents.create` + * is split into base channel `documents` plus action `create`. Add new actions + * (e.g. `delete`) here to extend support — no other code change is required. + */ + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert']; + /** * Connection Tree * @@ -21,7 +33,11 @@ class Realtime extends MessagingAdapter * 'projectId' -> [PROJECT_ID] * 'roles' -> [ROLE_x, ROLE_Y] * 'userId' -> [USER_ID] - * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] + * 'channels' -> [BASE_CHANNEL_X, BASE_CHANNEL_Y, BASE_CHANNEL_Z] + * + * Channels here are stored in their *base* form (action suffix stripped) so they + * line up with subscription-tree keys; the original action-prefixed channel is + * reconstructed from per-subscription `actions` metadata when needed. */ public array $connections = []; @@ -32,9 +48,13 @@ class Realtime extends MessagingAdapter * [ROLE_X] -> * [CHANNEL_NAME_X] -> * [CONNECTION_ID] -> - * [SUB_ID] -> ['strings' => [...], 'compiled' => [...]] + * [SUB_ID] -> ['strings' => [...], 'compiled' => [...], 'actions' => [...]] + * + * Each subscription ID maps to query strings (for metadata), pre-compiled query + * filters, and an `actions` metadata list. `actions` is `['*']` by default + * meaning "no action filter"; otherwise a list of action suffixes (e.g. `['create']`) + * that the event must end with for delivery. * - * Each subscription ID maps to query strings (for metadata) and pre-compiled query filters. * Within a subscription: AND logic (all queries must match) * Across subscriptions: OR logic (any subscription matching = send event) */ @@ -81,10 +101,33 @@ class Realtime extends MessagingAdapter $this->subscriptions[$projectId] = []; } + // Split each channel into a base form + an action suffix. Channels without a + // recognised suffix get action '*' (no filter). When the same base channel + // appears with multiple actions in this call (e.g. `documents.create` and + // `documents.update`), the actions are merged onto a single tree entry. + $actionsByBase = []; + $baseChannels = []; + foreach ($channels as $channel) { + [$base, $action] = self::parseActionChannel($channel); + if (!\in_array($base, $baseChannels, true)) { + $baseChannels[] = $base; + } + if (!isset($actionsByBase[$base])) { + $actionsByBase[$base] = [$action]; + continue; + } + // '*' subsumes any specific action — once present, drop the rest. + if (\in_array(self::ACTION_ALL, $actionsByBase[$base], true) || $action === self::ACTION_ALL) { + $actionsByBase[$base] = [self::ACTION_ALL]; + } elseif (!\in_array($action, $actionsByBase[$base], true)) { + $actionsByBase[$base][] = $action; + } + } + $strings = []; $data = []; - if (!empty($channels)) { + if (!empty($baseChannels)) { if (empty($queryGroup)) { $strings[] = Query::select(['*'])->toString(); } else { @@ -103,19 +146,24 @@ class Realtime extends MessagingAdapter $this->subscriptions[$projectId][$role] = []; } - foreach ($channels as $channel) { - if (!isset($this->subscriptions[$projectId][$role][$channel])) { - $this->subscriptions[$projectId][$role][$channel] = []; + foreach ($actionsByBase as $base => $actions) { + if (!isset($this->subscriptions[$projectId][$role][$base])) { + $this->subscriptions[$projectId][$role][$base] = []; } - if (!isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { - $this->subscriptions[$projectId][$role][$channel][$identifier] = []; + if (!isset($this->subscriptions[$projectId][$role][$base][$identifier])) { + $this->subscriptions[$projectId][$role][$base][$identifier] = []; } - $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $data; + + $channelData = $data; + $channelData['actions'] = $actions; + + $this->subscriptions[$projectId][$role][$base][$identifier][$subscriptionId] = $channelData; } } // Union channels/roles across all subscriptions on the connection; overwriting would // leave getSubscriptionMetadata and full unsubscribe operating on stale state. + // Channels are stored in *base* form here so they match subscription-tree keys. $existing = $this->connections[$identifier] ?? []; $existingChannels = $existing['channels'] ?? []; $existingRoles = $existing['roles'] ?? []; @@ -124,7 +172,7 @@ class Realtime extends MessagingAdapter 'projectId' => $projectId, 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), 'userId' => $userId ?? ($existing['userId'] ?? ''), - 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $baseChannels))), ]; if (\array_key_exists('authorization', $existing)) { @@ -171,8 +219,16 @@ class Realtime extends MessagingAdapter 'queries' => $data['strings'] ?? [] ]; } - if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) { - $subscriptions[$subscriptionId]['channels'][] = $channel; + + // Re-attach the action suffix so the original subscription channel + // (e.g. `documents.create`) is round-tripped on response paths and + // re-subscribe flows. `*` means no action was set — emit the base. + $actions = $data['actions'] ?? [self::ACTION_ALL]; + foreach ($actions as $action) { + $name = $action === self::ACTION_ALL ? $channel : $channel . '.' . $action; + if (!\in_array($name, $subscriptions[$subscriptionId]['channels'], true)) { + $subscriptions[$subscriptionId]['channels'][] = $name; + } } } } @@ -373,6 +429,7 @@ class Realtime extends MessagingAdapter } $payload = $event['data']['payload'] ?? []; + $events = $event['data']['events'] ?? []; foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) { foreach ($event['data']['channels'] as $channel) { @@ -389,6 +446,11 @@ class Realtime extends MessagingAdapter foreach ($subscriptions as $subscriptionId => $data) { $compiled = $data['compiled'] ?? ['type' => 'selectAll']; $strings = $data['strings'] ?? []; + $actions = $data['actions'] ?? [self::ACTION_ALL]; + + if (!self::matchesActions($actions, $events)) { + continue; + } if (RuntimeQuery::filter($compiled, $payload) !== null) { $matched[$subscriptionId] = $strings; @@ -408,6 +470,76 @@ class Realtime extends MessagingAdapter return $receivers; } + /** + * Tests whether any event in `$events` ends with one of the listed actions. + * + * Implements `containsAny` semantics (any-of match) over the events array, + * comparing only the trailing `.`-segment of each event so wildcard variants + * like `databases.*.collections.*.documents.*.create` match action `create`. + * `['*']` (or empty) means "no action filter" and short-circuits to true. + * + * @param array $actions Stored action filter for the subscription. + * @param array $events Event names from the published event. + * @return bool + */ + private static function matchesActions(array $actions, array $events): bool + { + if (empty($actions) || \in_array(self::ACTION_ALL, $actions, true)) { + return true; + } + + foreach ($events as $event) { + $lastDot = \strrpos($event, '.'); + if ($lastDot === false) { + continue; + } + if (\in_array(\substr($event, $lastDot + 1), $actions, true)) { + return true; + } + } + + return false; + } + + /** + * Splits a channel name into its base form and an action suffix. + * + * A trailing segment that matches one of {@see self::SUPPORTED_ACTIONS} is treated + * as an action filter and stripped from the channel; the remaining prefix becomes + * the base channel used for subscription-tree lookup. When no recognised suffix is + * present, the channel is returned unchanged with action {@see self::ACTION_ALL} + * (meaning "no action filter"). + * + * Examples: + * `documents.create` -> [`documents`, `create`] + * `databases.X.collections.Y.documents.Z.create` -> [`databases.X.collections.Y.documents.Z`, `create`] + * `documents` -> [`documents`, `*`] + * `account.create` -> already filtered out by convertChannels() + * + * @param string $channel + * @return array{0: string, 1: string} [baseChannel, action] + */ + public static function parseActionChannel(string $channel): array + { + $lastDot = \strrpos($channel, '.'); + if ($lastDot === false) { + return [$channel, self::ACTION_ALL]; + } + + $suffix = \substr($channel, $lastDot + 1); + if (!\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { + return [$channel, self::ACTION_ALL]; + } + + $base = \substr($channel, 0, $lastDot); + if ($base === '') { + // Pathological — channel was just ".create"; leave it alone. + return [$channel, self::ACTION_ALL]; + } + + return [$base, $suffix]; + } + /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index f48be46202..0706f8e89b 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -517,4 +517,264 @@ class MessagingTest extends TestCase $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } + + public function testParseActionChannel(): void + { + $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); + $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); + $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); + $this->assertSame( + ['databases.X.collections.Y.documents.Z', 'create'], + Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') + ); + + // No action suffix → unchanged with '*' default. + $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); + $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); + + // Unrecognised suffix (e.g. delete is not yet supported) → unchanged. + $this->assertSame(['documents.delete', '*'], Realtime::parseActionChannel('documents.delete')); + } + + public function testActionChannelFiltersByEventAction(): void + { + $realtime = new Realtime(); + + // Two subscriptions on the same connection: one filtered to creates only, + // one filtered to updates only. + $realtime->subscribe( + '1', + 1, + 'sub-create', + [Role::any()->toString()], + ['documents.create'], + ); + $realtime->subscribe( + '1', + 1, + 'sub-update', + [Role::any()->toString()], + ['documents.update'], + ); + + $createEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.create', + 'databases.*.collections.*.documents.*.create', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $updateEvent = $createEvent; + $updateEvent['data']['events'] = [ + 'databases.db.collections.col.documents.doc.update', + 'databases.*.collections.*.documents.*.update', + ]; + + // Create event should only deliver to sub-create. + $receivers = $realtime->getSubscribers($createEvent); + $this->assertCount(1, $receivers); + $this->assertArrayHasKey(1, $receivers); + $this->assertArrayHasKey('sub-create', $receivers[1]); + $this->assertArrayNotHasKey('sub-update', $receivers[1]); + + // Update event should only deliver to sub-update. + $receivers = $realtime->getSubscribers($updateEvent); + $this->assertCount(1, $receivers); + $this->assertArrayHasKey('sub-update', $receivers[1]); + $this->assertArrayNotHasKey('sub-create', $receivers[1]); + } + + public function testActionChannelHonorsResourceId(): void + { + $realtime = new Realtime(); + + // Subscribe to creates on a specific document only. + $realtime->subscribe( + '1', + 1, + 'sub-doc-create', + [Role::any()->toString()], + ['documents.789.create'], + ); + + // The base channel for `documents.789.create` is `documents.789`. + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents.789'], + 'events' => [ + 'databases.db.collections.col.documents.789.create', + 'databases.*.collections.*.documents.*.create', + ], + 'payload' => ['$id' => '789'], + ], + ]; + + $receivers = $realtime->getSubscribers($event); + $this->assertCount(1, $receivers); + $this->assertArrayHasKey('sub-doc-create', $receivers[1]); + + // Update on the same document should not match. + $event['data']['events'] = [ + 'databases.db.collections.col.documents.789.update', + 'databases.*.collections.*.documents.*.update', + ]; + + $this->assertEmpty($realtime->getSubscribers($event)); + + // Create on a different document should not match (different base channel + // entirely; subscription tree key won't even line up). + $event['data']['channels'] = ['documents.999']; + $event['data']['events'] = [ + 'databases.db.collections.col.documents.999.create', + 'databases.*.collections.*.documents.*.create', + ]; + + $this->assertEmpty($realtime->getSubscribers($event)); + } + + public function testNonActionChannelStillReceivesAllEvents(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-all', + [Role::any()->toString()], + ['documents'], + ); + + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.create', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + $event['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + $event['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + } + + public function testMixedActionAndBaseChannelInSameSubscription(): void + { + $realtime = new Realtime(); + + // Same sub-id covers `documents.create` (filtered) and `files` (unfiltered). + // After parsing they live under different base-channel keys with their own + // action metadata, so each gets its own filter behaviour. + $realtime->subscribe( + '1', + 1, + 'sub-mixed', + [Role::any()->toString()], + ['documents.create', 'files'], + ); + + // Create event on documents → matches. + $createDoc = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.create'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($createDoc)); + + // Update event on documents → blocked by the action filter on the documents key. + $updateDoc = $createDoc; + $updateDoc['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertEmpty($realtime->getSubscribers($updateDoc)); + + // Files channel has no action filter — any action delivers. + $updateFile = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['files'], + 'events' => ['buckets.bucket.files.file.update'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateFile)); + } + + public function testActionChannelMetadataRoundTrips(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-create', + [Role::any()->toString()], + ['documents.create', 'files'], + ); + + $meta = $realtime->getSubscriptionMetadata(1); + + $this->assertArrayHasKey('sub-create', $meta); + $this->assertContains('documents.create', $meta['sub-create']['channels']); + $this->assertContains('files', $meta['sub-create']['channels']); + // Base form should NOT leak when an action was set. + $this->assertNotContains('documents', $meta['sub-create']['channels']); + } + + public function testMergingMultipleActionsOnSameBaseChannel(): void + { + $realtime = new Realtime(); + + // Subscribing to multiple actions on the same base merges their action lists + // onto a single tree entry. + $realtime->subscribe( + '1', + 1, + 'sub-multi', + [Role::any()->toString()], + ['documents.create', 'documents.update'], + ); + + $createEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.create'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); + + $updateEvent = $createEvent; + $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); + + // Upsert should not match — neither create nor update covers it. + $upsertEvent = $createEvent; + $upsertEvent['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; + $this->assertEmpty($realtime->getSubscribers($upsertEvent)); + + $meta = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.create', $meta['sub-multi']['channels']); + $this->assertContains('documents.update', $meta['sub-multi']['channels']); + } } From d2423a5bb51e5fe0d1ec34c9c38458caa77fefbd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 12:57:35 +0530 Subject: [PATCH 02/56] added tests --- .../Services/Realtime/RealtimeQueryBase.php | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04b8400b57..24d2a3511a 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2446,4 +2446,424 @@ trait RealtimeQueryBase $clientWithMatchingQuery->close(); $clientWithNonMatchingQuery->close(); } + + /** + * Sets up a database + collection + 'name' string attribute, returning their IDs. + * Used by action-channel tests to avoid duplicating fixture code. + * + * @return array{databaseId: string, collectionId: string} + */ + private function createActorsCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Action Channel DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + return ['databaseId' => $databaseId, 'collectionId' => $collectionId]; + } + + /** + * Creates a document with the given ID and name. Returns the parsed body. + * Permissions allow Role::any() for all CRUD so any session can observe the events. + * + * @return array + */ + private function createActor(string $databaseId, string $collectionId, string $documentId, string $name): array + { + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => ['name' => $name], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + return $document['body']; + } + + public function testChannelActionFilterReflectedInConnectedResponse(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + // Subscribing with an action suffix should round-trip the original channel + // name on the connected response. Only meaningful in URL-subscribe mode — + // the message-based path consumes the connected response inside its + // getWebsocket helper before returning, so we can't observe it here. + $client = $this->getWebsocket([ + 'documents.create', + 'documents.update', + 'documents.upsert', + 'documents', + ], $headers); + + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected === null) { + $client->close(); + $this->markTestSkipped('Connected-response channels are not surfaced through the message-based subscribe path.'); + } + + $this->assertContains('documents.create', $connected['data']['channels']); + $this->assertContains('documents.update', $connected['data']['channels']); + $this->assertContains('documents.upsert', $connected['data']['channels']); + $this->assertContains('documents', $connected['data']['channels']); + + $client->close(); + } + + public function testChannelActionFilterDeliversOnlyMatchingActions(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $createChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.create"; + $updateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.update"; + $upsertChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.upsert"; + + $clientCreate = $this->getWebsocket([$createChannel], $headers); + $clientUpdate = $this->getWebsocket([$updateChannel], $headers); + $clientUpsert = $this->getWebsocket([$upsertChannel], $headers); + + $this->assertConnectionStatusIfSupported($clientCreate); + $this->assertConnectionStatusIfSupported($clientUpdate); + $this->assertConnectionStatusIfSupported($clientUpsert); + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'Chris Evans'); + + // Create event delivers only to the .create subscriber. + $createEvent = json_decode($clientCreate->receive(), true); + $this->assertEquals('event', $createEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.create", + $createEvent['data']['events'] + ); + $this->assertEquals('Chris Evans', $createEvent['data']['payload']['name']); + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Update fires update events; only the .update subscriber should hear them. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Chris Evans 2'], + ]); + + $updateEvent = json_decode($clientUpdate->receive(), true); + $this->assertEquals('event', $updateEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.update", + $updateEvent['data']['events'] + ); + $this->assertEquals('Chris Evans 2', $updateEvent['data']['payload']['name']); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // PUT bulk upsert fires upsert events; only the .upsert subscriber should hear them. + $this->client->call(Client::METHOD_PUT, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + ], + ]); + + $upsertEvent = json_decode($clientUpsert->receive(), true); + $this->assertEquals('event', $upsertEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.*.documents.*.upsert", + $upsertEvent['data']['events'] + ); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientCreate->close(); + $clientUpdate->close(); + $clientUpsert->close(); + } + + public function testChannelActionFilterByDocumentId(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // Use a known custom ID so the .id.action channel can be subscribed before the + // document exists. Without this the channel name can't be predicted. + $watchedId = 'actor-watched'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + + $clientWatched = $this->getWebsocket([$idCreateChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientWatched); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + } + + // Creating a *different* document should not trigger the watched-id subscription. + $this->createActor($databaseId, $collectionId, ID::unique(), 'Other Actor'); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive events for a different document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Creating the watched document delivers exactly one create event. + $this->createActor($databaseId, $collectionId, $watchedId, 'Watched Actor'); + + $event = json_decode($clientWatched->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals($watchedId, $event['data']['payload']['$id']); + $this->assertEquals('Watched Actor', $event['data']['payload']['name']); + + // Updating the watched document does NOT match — action filter is `create` only. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Watched Actor v2'], + ]); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive update events on the same document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientWatched->close(); + } + + public function testChannelActionFilterMultiChannelSubscription(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $watchedId = 'actor-multi'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + $rowsChannel = "databases.{$databaseId}.tables.{$collectionId}.rows"; + + // One subscription that listens on both: + // 1. `databases...documents.{watchedId}.create` — narrow, action-filtered + // 2. `databases...tables.{collectionId}.rows` — broad, non-action (tablesdb mirror) + // A create on the watched document must reach this subscriber via *both* channels. + $clientMulti = $this->getWebsocket([$idCreateChannel, $rowsChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientMulti); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + $this->assertContains($rowsChannel, $connected['data']['channels']); + } + + $this->createActor($databaseId, $collectionId, $watchedId, 'Multi Actor'); + + $event = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $event['type']); + // The event payload's channels list reports the underlying base channels that + // the published event carries. Both the broad rows channel and the document + // channel that the action filter is anchored on should be present. + $this->assertContains($rowsChannel, $event['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}", + $event['data']['channels'] + ); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals('Multi Actor', $event['data']['payload']['name']); + + // Update on the same doc: the .{id}.create branch is filtered out, but the + // broad rows channel has no action filter — the subscription still receives + // the event via that branch (a single delivery, not two). + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Multi Actor v2'], + ]); + + $update = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $update['type']); + $this->assertContains($rowsChannel, $update['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.update", + $update['data']['events'] + ); + + // No second copy of the same update should arrive — getSubscribers folds + // multi-channel matches into a single connection delivery. + try { + $clientMulti->receive(); + $this->fail('Multi-channel subscriber should receive a single delivery per event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientMulti->close(); + } + + public function testChannelActionFilterUnsupportedActionTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // `delete` is intentionally NOT in SUPPORTED_ACTIONS yet, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.delete'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.delete', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Delete Listener'); + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + try { + $client->receive(); + $this->fail('`documents.delete` is not (yet) a supported action channel and should not deliver.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $client->close(); + } } From 3aee54747caa6ff601149af2f2beee108d3852f4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 13:15:04 +0530 Subject: [PATCH 03/56] Enhance Realtime adapter to support delete action and add corresponding tests --- src/Appwrite/Messaging/Adapter/Realtime.php | 4 +- .../Services/Realtime/RealtimeQueryBase.php | 65 ++++++++++++++++--- tests/unit/Messaging/MessagingTest.php | 47 +++++++++++++- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index a03ccecfd9..f01b02ba21 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -22,9 +22,9 @@ class Realtime extends MessagingAdapter /** * Action suffixes recognized in channel names. A channel like `documents.create` * is split into base channel `documents` plus action `create`. Add new actions - * (e.g. `delete`) here to extend support — no other code change is required. + * here to extend support — no other code change is required. */ - public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert']; + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; /** * Connection Tree diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 24d2a3511a..5ab5c26253 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2827,7 +2827,7 @@ trait RealtimeQueryBase $clientMulti->close(); } - public function testChannelActionFilterUnsupportedActionTreatedAsLiteral(): void + public function testChannelActionFilterDeliversDeleteEvents(): void { $user = $this->getUser(); $session = $user['session'] ?? ''; @@ -2840,17 +2840,64 @@ trait RealtimeQueryBase ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); - // `delete` is intentionally NOT in SUPPORTED_ACTIONS yet, so parseActionChannel - // leaves the channel name intact and treats it as a literal channel that no - // published event ever carries — the subscriber should receive nothing. - $client = $this->getWebsocket(['documents.delete'], $headers); - $connected = $this->assertConnectionStatusIfSupported($client); + $deleteChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.delete"; + $clientDelete = $this->getWebsocket([$deleteChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientDelete); if ($connected !== null) { - $this->assertContains('documents.delete', $connected['data']['channels']); + $this->assertContains($deleteChannel, $connected['data']['channels']); } $documentId = ID::unique(); - $this->createActor($databaseId, $collectionId, $documentId, 'No Delete Listener'); + $this->createActor($databaseId, $collectionId, $documentId, 'About To Be Deleted'); + + // Create event must not arrive — the action filter is `delete`. + try { + $clientDelete->receive(); + $this->fail('Delete subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $deleteEvent = json_decode($clientDelete->receive(), true); + $this->assertEquals('event', $deleteEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.delete", + $deleteEvent['data']['events'] + ); + $this->assertEquals($documentId, $deleteEvent['data']['payload']['$id']); + + $clientDelete->close(); + } + + public function testChannelActionFilterUnknownSuffixTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // An unrecognised suffix is NOT in SUPPORTED_ACTIONS, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.bogus'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.bogus', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Bogus Listener'); $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ 'content-type' => 'application/json', @@ -2859,7 +2906,7 @@ trait RealtimeQueryBase try { $client->receive(); - $this->fail('`documents.delete` is not (yet) a supported action channel and should not deliver.'); + $this->fail('Unrecognised action suffix should not deliver any events.'); } catch (TimeoutException $e) { $this->addToAssertionCount(1); } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 0706f8e89b..1fde8e6bc2 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -523,17 +523,22 @@ class MessagingTest extends TestCase $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); + $this->assertSame(['documents', 'delete'], Realtime::parseActionChannel('documents.delete')); $this->assertSame( ['databases.X.collections.Y.documents.Z', 'create'], Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') ); + $this->assertSame( + ['databases.X.collections.Y.documents.Z', 'delete'], + Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.delete') + ); // No action suffix → unchanged with '*' default. $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); - // Unrecognised suffix (e.g. delete is not yet supported) → unchanged. - $this->assertSame(['documents.delete', '*'], Realtime::parseActionChannel('documents.delete')); + // Unrecognised suffix → unchanged (treated as literal channel name). + $this->assertSame(['documents.bogus', '*'], Realtime::parseActionChannel('documents.bogus')); } public function testActionChannelFiltersByEventAction(): void @@ -590,6 +595,44 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('sub-create', $receivers[1]); } + public function testActionChannelDeleteFilter(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-delete', + [Role::any()->toString()], + ['documents.delete'], + ); + + $deleteEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.delete', + 'databases.*.collections.*.documents.*.delete', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $receivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(1, $receivers); + $this->assertArrayHasKey('sub-delete', $receivers[1]); + + // Other actions on the same base channel should not match the delete filter. + $createEvent = $deleteEvent; + $createEvent['data']['events'] = [ + 'databases.db.collections.col.documents.doc.create', + 'databases.*.collections.*.documents.*.create', + ]; + $this->assertEmpty($realtime->getSubscribers($createEvent)); + } + public function testActionChannelHonorsResourceId(): void { $realtime = new Realtime(); From 6d4a66fbb380f398e5680dcea431ddd920818d09 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 13:30:18 +0530 Subject: [PATCH 04/56] Enhance Realtime adapter to support action-channel awareness in subscriber checks and add corresponding tests --- src/Appwrite/Messaging/Adapter/Realtime.php | 51 +++++++++-- tests/unit/Messaging/MessagingTest.php | 98 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f01b02ba21..66052bda3d 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -105,6 +105,13 @@ class Realtime extends MessagingAdapter // recognised suffix get action '*' (no filter). When the same base channel // appears with multiple actions in this call (e.g. `documents.create` and // `documents.update`), the actions are merged onto a single tree entry. + // + // We keep '*' alongside specific actions when both are subscribed to (e.g. + // `[documents, documents.create]`). matchesActions short-circuits on '*' so + // event delivery is unchanged, but getSubscriptionMetadata can faithfully + // round-trip both channel names through re-auth / permissions-changed flows + // — otherwise the `.create` would be dropped and the resubscribed entry + // would silently broaden its semantics on the next refresh. $actionsByBase = []; $baseChannels = []; foreach ($channels as $channel) { @@ -116,10 +123,7 @@ class Realtime extends MessagingAdapter $actionsByBase[$base] = [$action]; continue; } - // '*' subsumes any specific action — once present, drop the rest. - if (\in_array(self::ACTION_ALL, $actionsByBase[$base], true) || $action === self::ACTION_ALL) { - $actionsByBase[$base] = [self::ACTION_ALL]; - } elseif (!\in_array($action, $actionsByBase[$base], true)) { + if (!\in_array($action, $actionsByBase[$base], true)) { $actionsByBase[$base][] = $action; } } @@ -355,6 +359,13 @@ class Realtime extends MessagingAdapter /** * Checks if Channel has a subscriber. + * + * Action-channel aware: if `$channel` carries a recognised action suffix + * (e.g. `documents.create`), the lookup is performed against the *base* + * channel in the tree and additionally requires at least one subscription + * whose `actions` list includes that action (or `'*'`, which subsumes it). + * Plain channel names are matched as before. + * * @param string $projectId * @param string $role * @param string $channel @@ -368,10 +379,34 @@ class Realtime extends MessagingAdapter && array_key_exists($role, $this->subscriptions[$projectId]); } - return array_key_exists($projectId, $this->subscriptions) - && array_key_exists($role, $this->subscriptions[$projectId]) - && array_key_exists($channel, $this->subscriptions[$projectId][$role]) - && !empty($this->subscriptions[$projectId][$role][$channel]); + [$base, $action] = self::parseActionChannel($channel); + + if ( + !array_key_exists($projectId, $this->subscriptions) + || !array_key_exists($role, $this->subscriptions[$projectId]) + || !array_key_exists($base, $this->subscriptions[$projectId][$role]) + || empty($this->subscriptions[$projectId][$role][$base]) + ) { + return false; + } + + // Plain channel — any subscription on the base counts. + if ($action === self::ACTION_ALL) { + return true; + } + + // Action-specific channel — require a subscription whose actions list + // includes the action (or '*'). + foreach ($this->subscriptions[$projectId][$role][$base] as $byConnection) { + foreach ($byConnection as $data) { + $actions = $data['actions'] ?? [self::ACTION_ALL]; + if (\in_array(self::ACTION_ALL, $actions, true) || \in_array($action, $actions, true)) { + return true; + } + } + } + + return false; } /** diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 1fde8e6bc2..aecb3894eb 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -518,6 +518,56 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } + public function testHasSubscriberIsActionChannelAware(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-create', + [Role::any()->toString()], + ['documents.create'], + ); + + // Plain base lookup hits the subscription. + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); + + // Action-channel lookup matches when the action is in the stored list. + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); + + // Action-channel lookup misses when the action is not stored — even though + // the base channel exists. + $this->assertFalse($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); + + // Unknown project / role still resolves to false. + $this->assertFalse($realtime->hasSubscriber('nope', Role::any()->toString(), 'documents.create')); + $this->assertFalse($realtime->hasSubscriber('1', 'role:other', 'documents.create')); + + // No-channel form still works. + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString())); + } + + public function testHasSubscriberWildcardActionsSubsumeSpecific(): void + { + $realtime = new Realtime(); + + // Subscribing to plain `documents` stores actions = ['*']. Any action-channel + // lookup against the same base must succeed because '*' subsumes specific actions. + $realtime->subscribe( + '1', + 1, + 'sub-all', + [Role::any()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.delete')); + } + public function testParseActionChannel(): void { $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); @@ -782,6 +832,54 @@ class MessagingTest extends TestCase $this->assertNotContains('documents', $meta['sub-create']['channels']); } + public function testActionAndBaseChannelTogetherRoundTripsLosslessly(): void + { + $realtime = new Realtime(); + + // Subscribing with both a specific-action channel AND its plain base form must + // preserve both names: '*' short-circuits delivery (so update events still + // come through), but the metadata kept for re-auth/permissions-changed flows + // would otherwise drop `documents.create` entirely on the next refresh. + $realtime->subscribe( + '1', + 1, + 'sub-mixed', + [Role::any()->toString()], + ['documents.create', 'documents'], + ); + + $meta = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.create', $meta['sub-mixed']['channels']); + $this->assertContains('documents', $meta['sub-mixed']['channels']); + + // Update events still deliver because '*' is in the actions list. + $updateEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.update'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); + + // Round-trip: feed the metadata back through subscribe() and the original + // pair of channel names must come out again. + $realtime->unsubscribe(1); + $realtime->subscribe( + '1', + 1, + 'sub-mixed', + [Role::any()->toString()], + $meta['sub-mixed']['channels'], + ); + + $metaAgain = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.create', $metaAgain['sub-mixed']['channels']); + $this->assertContains('documents', $metaAgain['sub-mixed']['channels']); + } + public function testMergingMultipleActionsOnSameBaseChannel(): void { $realtime = new Realtime(); From df57ee2a321b5d4e40ed90cc15806bfe5832fd76 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 13:43:23 +0530 Subject: [PATCH 05/56] added unit test --- tests/unit/Messaging/MessagingTest.php | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index aecb3894eb..c82a55e438 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -832,6 +832,45 @@ class MessagingTest extends TestCase $this->assertNotContains('documents', $meta['sub-create']['channels']); } + public function testSubscribeWithSameSubIdReplacesActionsNotMerges(): void + { + $realtime = new Realtime(); + $role = Role::any()->toString(); + + // Initial subscribe: only `create` events on the documents base. + $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.create']); + + $createEvent = [ + 'project' => '1', + 'roles' => [$role], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.create'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); + + // Re-subscribe with the SAME sub-id but a different action. Per the upsert + // contract documented on Realtime::subscribe, this fully replaces the prior + // state — actions are NOT unioned across calls (channels and queries already + // followed replace-not-merge semantics; actions match that rule). + $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.update']); + + // Create no longer matches: previous filter is gone. + $this->assertEmpty($realtime->getSubscribers($createEvent)); + + // Update now matches. + $updateEvent = $createEvent; + $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); + + // Metadata reflects only the new state. + $meta = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.update', $meta['sub-x']['channels']); + $this->assertNotContains('documents.create', $meta['sub-x']['channels']); + } + public function testActionAndBaseChannelTogetherRoundTripsLosslessly(): void { $realtime = new Realtime(); From 78715e4a1a8386e1f9a9e9da57353a7564191da4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 15:46:02 +0530 Subject: [PATCH 06/56] refactor(tests): rename test methods to snake_case and update assertions for action channels - Changed test method names from camelCase to snake_case for consistency. - Updated assertions to ensure action channels are correctly emitted and filtered. - Improved readability and maintainability of the test suite by restructuring test cases. --- src/Appwrite/Messaging/Adapter/Realtime.php | 390 ++++--------- tests/unit/Messaging/MessagingTest.php | 612 ++++++-------------- 2 files changed, 288 insertions(+), 714 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 66052bda3d..7be0911b8c 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,18 +14,19 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { - /** - * Action suffix that means "all actions" — i.e. no action filter on this subscription. - */ - public const ACTION_ALL = '*'; - - /** - * Action suffixes recognized in channel names. A channel like `documents.create` - * is split into base channel `documents` plus action `create`. Add new actions - * here to extend support — no other code change is required. - */ public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + private const RESOURCE_LEAF_NAMES = [ + 'documents', + 'rows', + 'files', + 'executions', + 'functions', + 'account', + 'teams', + 'memberships', + ]; + /** * Connection Tree * @@ -33,11 +34,7 @@ class Realtime extends MessagingAdapter * 'projectId' -> [PROJECT_ID] * 'roles' -> [ROLE_x, ROLE_Y] * 'userId' -> [USER_ID] - * 'channels' -> [BASE_CHANNEL_X, BASE_CHANNEL_Y, BASE_CHANNEL_Z] - * - * Channels here are stored in their *base* form (action suffix stripped) so they - * line up with subscription-tree keys; the original action-prefixed channel is - * reconstructed from per-subscription `actions` metadata when needed. + * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] */ public array $connections = []; @@ -48,13 +45,9 @@ class Realtime extends MessagingAdapter * [ROLE_X] -> * [CHANNEL_NAME_X] -> * [CONNECTION_ID] -> - * [SUB_ID] -> ['strings' => [...], 'compiled' => [...], 'actions' => [...]] - * - * Each subscription ID maps to query strings (for metadata), pre-compiled query - * filters, and an `actions` metadata list. `actions` is `['*']` by default - * meaning "no action filter"; otherwise a list of action suffixes (e.g. `['create']`) - * that the event must end with for delivery. + * [SUB_ID] -> ['strings' => [...], 'compiled' => [...]] * + * Each subscription ID maps to query strings (for metadata) and pre-compiled query filters. * Within a subscription: AND logic (all queries must match) * Across subscriptions: OR logic (any subscription matching = send event) */ @@ -65,8 +58,6 @@ class Realtime extends MessagingAdapter /** * Get the PubSubPool instance, initializing it lazily if needed. * This allows unit tests to work without requiring the global $register. - * - * @return PubSubPool */ private function getPubSubPool(): PubSubPool { @@ -74,19 +65,18 @@ class Realtime extends MessagingAdapter global $register; $this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub')); } + return $this->pubSubPool; } /** * Adds a subscription with a specific subscription ID. * - * @param string $projectId - * @param mixed $identifier Connection ID - * @param string $subscriptionId Unique subscription ID - * @param array $roles User roles - * @param array $channels Channels to subscribe to (array of channel names) - * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) - * @return void + * @param mixed $identifier Connection ID + * @param string $subscriptionId Unique subscription ID + * @param array $roles User roles + * @param array $channels Channels to subscribe to (array of channel names) + * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) */ public function subscribe( string $projectId, @@ -97,41 +87,14 @@ class Realtime extends MessagingAdapter array $queryGroup = [], ?string $userId = null ): void { - if (!isset($this->subscriptions[$projectId])) { // Init Project + if (! isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; } - // Split each channel into a base form + an action suffix. Channels without a - // recognised suffix get action '*' (no filter). When the same base channel - // appears with multiple actions in this call (e.g. `documents.create` and - // `documents.update`), the actions are merged onto a single tree entry. - // - // We keep '*' alongside specific actions when both are subscribed to (e.g. - // `[documents, documents.create]`). matchesActions short-circuits on '*' so - // event delivery is unchanged, but getSubscriptionMetadata can faithfully - // round-trip both channel names through re-auth / permissions-changed flows - // — otherwise the `.create` would be dropped and the resubscribed entry - // would silently broaden its semantics on the next refresh. - $actionsByBase = []; - $baseChannels = []; - foreach ($channels as $channel) { - [$base, $action] = self::parseActionChannel($channel); - if (!\in_array($base, $baseChannels, true)) { - $baseChannels[] = $base; - } - if (!isset($actionsByBase[$base])) { - $actionsByBase[$base] = [$action]; - continue; - } - if (!\in_array($action, $actionsByBase[$base], true)) { - $actionsByBase[$base][] = $action; - } - } - $strings = []; $data = []; - if (!empty($baseChannels)) { + if (! empty($channels)) { if (empty($queryGroup)) { $strings[] = Query::select(['*'])->toString(); } else { @@ -146,28 +109,23 @@ class Realtime extends MessagingAdapter } foreach ($roles as $role) { - if (!isset($this->subscriptions[$projectId][$role])) { + if (! isset($this->subscriptions[$projectId][$role])) { $this->subscriptions[$projectId][$role] = []; } - foreach ($actionsByBase as $base => $actions) { - if (!isset($this->subscriptions[$projectId][$role][$base])) { - $this->subscriptions[$projectId][$role][$base] = []; + foreach ($channels as $channel) { + if (! isset($this->subscriptions[$projectId][$role][$channel])) { + $this->subscriptions[$projectId][$role][$channel] = []; } - if (!isset($this->subscriptions[$projectId][$role][$base][$identifier])) { - $this->subscriptions[$projectId][$role][$base][$identifier] = []; + if (! isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { + $this->subscriptions[$projectId][$role][$channel][$identifier] = []; } - - $channelData = $data; - $channelData['actions'] = $actions; - - $this->subscriptions[$projectId][$role][$base][$identifier][$subscriptionId] = $channelData; + $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $data; } } // Union channels/roles across all subscriptions on the connection; overwriting would // leave getSubscriptionMetadata and full unsubscribe operating on stale state. - // Channels are stored in *base* form here so they match subscription-tree keys. $existing = $this->connections[$identifier] ?? []; $existingChannels = $existing['channels'] ?? []; $existingRoles = $existing['roles'] ?? []; @@ -176,7 +134,7 @@ class Realtime extends MessagingAdapter 'projectId' => $projectId, 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), 'userId' => $userId ?? ($existing['userId'] ?? ''), - 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $baseChannels))), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), ]; if (\array_key_exists('authorization', $existing)) { @@ -190,7 +148,7 @@ class Realtime extends MessagingAdapter * Get subscription metadata for a connection. * Retrieves subscription data including channels and queries directly from the subscriptions tree. * - * @param mixed $connection Connection ID + * @param mixed $connection Connection ID * @return array Array of [subscriptionId => ['channels' => string[], 'queries' => string[]]] */ public function getSubscriptionMetadata(mixed $connection): array @@ -199,7 +157,7 @@ class Realtime extends MessagingAdapter $roles = $this->connections[$connection]['roles'] ?? []; $channels = $this->connections[$connection]['channels'] ?? []; - if (!$projectId || empty($roles) || empty($channels)) { + if (! $projectId || empty($roles) || empty($channels)) { return []; } @@ -207,32 +165,24 @@ class Realtime extends MessagingAdapter // Extract subscription data from subscriptions tree foreach ($roles as $role) { - if (!isset($this->subscriptions[$projectId][$role])) { + if (! isset($this->subscriptions[$projectId][$role])) { continue; } foreach ($channels as $channel) { - if (!isset($this->subscriptions[$projectId][$role][$channel][$connection])) { + if (! isset($this->subscriptions[$projectId][$role][$channel][$connection])) { continue; } foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subscriptionId => $data) { - if (!isset($subscriptions[$subscriptionId])) { + if (! isset($subscriptions[$subscriptionId])) { $subscriptions[$subscriptionId] = [ 'channels' => [], - 'queries' => $data['strings'] ?? [] + 'queries' => $data['strings'] ?? [], ]; } - - // Re-attach the action suffix so the original subscription channel - // (e.g. `documents.create`) is round-tripped on response paths and - // re-subscribe flows. `*` means no action was set — emit the base. - $actions = $data['actions'] ?? [self::ACTION_ALL]; - foreach ($actions as $action) { - $name = $action === self::ACTION_ALL ? $channel : $channel . '.' . $action; - if (!\in_array($name, $subscriptions[$subscriptionId]['channels'], true)) { - $subscriptions[$subscriptionId]['channels'][] = $name; - } + if (! \in_array($channel, $subscriptions[$subscriptionId]['channels'])) { + $subscriptions[$subscriptionId]['channels'][] = $channel; } } } @@ -243,9 +193,6 @@ class Realtime extends MessagingAdapter /** * Removes all subscriptions for a connection. - * - * @param mixed $connection - * @return void */ public function unsubscribe(mixed $connection): void { @@ -279,15 +226,11 @@ class Realtime extends MessagingAdapter /** * Removes a single subscription from a connection, keeping the connection alive so * the client can resubscribe. Idempotent — returns true only when something was removed. - * - * @param mixed $connection - * @param string $subscriptionId - * @return bool */ public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool { $projectId = $this->connections[$connection]['projectId'] ?? ''; - if ($projectId === '' || !isset($this->subscriptions[$projectId])) { + if ($projectId === '' || ! isset($this->subscriptions[$projectId])) { return false; } @@ -295,7 +238,7 @@ class Realtime extends MessagingAdapter foreach ($this->subscriptions[$projectId] as $role => $byChannel) { foreach ($byChannel as $channel => $byConnection) { - if (!isset($byConnection[$connection][$subscriptionId])) { + if (! isset($byConnection[$connection][$subscriptionId])) { continue; } @@ -333,13 +276,10 @@ class Realtime extends MessagingAdapter * context (set at onOpen, replaced on `authentication` / permission-change) and must survive * per-subscription removal — otherwise a client that unsubscribes every subscription and then * resubscribes would subscribe with an empty roles array and silently receive nothing. - * - * @param mixed $connection - * @return void */ private function recomputeConnectionState(mixed $connection): void { - if (!isset($this->connections[$connection])) { + if (! isset($this->connections[$connection])) { return; } @@ -359,65 +299,24 @@ class Realtime extends MessagingAdapter /** * Checks if Channel has a subscriber. - * - * Action-channel aware: if `$channel` carries a recognised action suffix - * (e.g. `documents.create`), the lookup is performed against the *base* - * channel in the tree and additionally requires at least one subscription - * whose `actions` list includes that action (or `'*'`, which subsumes it). - * Plain channel names are matched as before. - * - * @param string $projectId - * @param string $role - * @param string $channel - * @return bool */ public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool { - //TODO: look into moving it to an abstract class in the parent class + // TODO: look into moving it to an abstract class in the parent class if (empty($channel)) { return array_key_exists($projectId, $this->subscriptions) && array_key_exists($role, $this->subscriptions[$projectId]); } - [$base, $action] = self::parseActionChannel($channel); - - if ( - !array_key_exists($projectId, $this->subscriptions) - || !array_key_exists($role, $this->subscriptions[$projectId]) - || !array_key_exists($base, $this->subscriptions[$projectId][$role]) - || empty($this->subscriptions[$projectId][$role][$base]) - ) { - return false; - } - - // Plain channel — any subscription on the base counts. - if ($action === self::ACTION_ALL) { - return true; - } - - // Action-specific channel — require a subscription whose actions list - // includes the action (or '*'). - foreach ($this->subscriptions[$projectId][$role][$base] as $byConnection) { - foreach ($byConnection as $data) { - $actions = $data['actions'] ?? [self::ACTION_ALL]; - if (\in_array(self::ACTION_ALL, $actions, true) || \in_array($action, $actions, true)) { - return true; - } - } - } - - return false; + return array_key_exists($projectId, $this->subscriptions) + && array_key_exists($role, $this->subscriptions[$projectId]) + && array_key_exists($channel, $this->subscriptions[$projectId][$role]) + && ! empty($this->subscriptions[$projectId][$role][$channel]); } /** * Sends an event to the Realtime Server - * @param string $projectId - * @param array $payload - * @param array $events - * @param array $channels - * @param array $roles - * @param array $options - * @return void + * * @throws \Exception */ public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void @@ -438,8 +337,8 @@ class Realtime extends MessagingAdapter 'events' => $events, 'channels' => $channels, 'timestamp' => DateTime::formatTz(DateTime::now()), - 'payload' => $payload - ] + 'payload' => $payload, + ], ])); } @@ -452,25 +351,23 @@ class Realtime extends MessagingAdapter * - 1.5 ms | 1,000 Connections / 10,000 Subscriptions * - 15 ms | 10,000 Connections / 100,000 Subscriptions * - * @param array $event * @return array Map of connection IDs to matched query groups */ public function getSubscribers(array $event): array { $receivers = []; - if (!isset($this->subscriptions[$event['project']])) { + if (! isset($this->subscriptions[$event['project']])) { return $receivers; } $payload = $event['data']['payload'] ?? []; - $events = $event['data']['events'] ?? []; foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) { foreach ($event['data']['channels'] as $channel) { if ( - !\array_key_exists($channel, $subscriptionsByChannel) - || (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles'])) + ! \array_key_exists($channel, $subscriptionsByChannel) + || (! \in_array($role, $event['roles']) && ! \in_array(Role::any()->toString(), $event['roles'])) ) { continue; } @@ -481,19 +378,14 @@ class Realtime extends MessagingAdapter foreach ($subscriptions as $subscriptionId => $data) { $compiled = $data['compiled'] ?? ['type' => 'selectAll']; $strings = $data['strings'] ?? []; - $actions = $data['actions'] ?? [self::ACTION_ALL]; - - if (!self::matchesActions($actions, $events)) { - continue; - } if (RuntimeQuery::filter($compiled, $payload) !== null) { $matched[$subscriptionId] = $strings; } } - if (!empty($matched)) { - if (!isset($receivers[$id])) { + if (! empty($matched)) { + if (! isset($receivers[$id])) { $receivers[$id] = []; } $receivers[$id] += $matched; @@ -505,82 +397,9 @@ class Realtime extends MessagingAdapter return $receivers; } - /** - * Tests whether any event in `$events` ends with one of the listed actions. - * - * Implements `containsAny` semantics (any-of match) over the events array, - * comparing only the trailing `.`-segment of each event so wildcard variants - * like `databases.*.collections.*.documents.*.create` match action `create`. - * `['*']` (or empty) means "no action filter" and short-circuits to true. - * - * @param array $actions Stored action filter for the subscription. - * @param array $events Event names from the published event. - * @return bool - */ - private static function matchesActions(array $actions, array $events): bool - { - if (empty($actions) || \in_array(self::ACTION_ALL, $actions, true)) { - return true; - } - - foreach ($events as $event) { - $lastDot = \strrpos($event, '.'); - if ($lastDot === false) { - continue; - } - if (\in_array(\substr($event, $lastDot + 1), $actions, true)) { - return true; - } - } - - return false; - } - - /** - * Splits a channel name into its base form and an action suffix. - * - * A trailing segment that matches one of {@see self::SUPPORTED_ACTIONS} is treated - * as an action filter and stripped from the channel; the remaining prefix becomes - * the base channel used for subscription-tree lookup. When no recognised suffix is - * present, the channel is returned unchanged with action {@see self::ACTION_ALL} - * (meaning "no action filter"). - * - * Examples: - * `documents.create` -> [`documents`, `create`] - * `databases.X.collections.Y.documents.Z.create` -> [`databases.X.collections.Y.documents.Z`, `create`] - * `documents` -> [`documents`, `*`] - * `account.create` -> already filtered out by convertChannels() - * - * @param string $channel - * @return array{0: string, 1: string} [baseChannel, action] - */ - public static function parseActionChannel(string $channel): array - { - $lastDot = \strrpos($channel, '.'); - if ($lastDot === false) { - return [$channel, self::ACTION_ALL]; - } - - $suffix = \substr($channel, $lastDot + 1); - if (!\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { - return [$channel, self::ACTION_ALL]; - } - - $base = \substr($channel, 0, $lastDot); - if ($base === '') { - // Pathological — channel was just ".create"; leave it alone. - return [$channel, self::ACTION_ALL]; - } - - return [$base, $suffix]; - } - /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. - * @param array $channels - * @param string $userId - * @return array */ public static function convertChannels(array $channels, string $userId): array { @@ -593,8 +412,8 @@ class Realtime extends MessagingAdapter break; case $key === 'account': - if (!empty($userId)) { - $channels['account.' . $userId] = $value; + if (! empty($userId)) { + $channels['account.'.$userId] = $value; } break; } @@ -606,9 +425,8 @@ class Realtime extends MessagingAdapter /** * Constructs subscriptions from query parameters. * - * @param array $channelNames - * @param callable $getQueryParam * @return array [index => ['channels' => string[], 'queries' => Query[]]] + * * @throws QueryException */ public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array @@ -642,26 +460,27 @@ class Realtime extends MessagingAdapter } if ($params === null) { - if (!isset($subscriptions[0])) { + if (! isset($subscriptions[0])) { $subscriptions[0] = ['channels' => [], 'queries' => []]; } $subscriptions[0]['channels'][] = $channel; if (empty($subscriptions[0]['queries'])) { $subscriptions[0]['queries'] = [Query::select(['*'])]; } + continue; } - if (!\is_array($params)) { + if (! \is_array($params)) { $params = [$params]; } foreach ($params as $index => $slot) { - if (!isset($subscriptions[$index])) { + if (! isset($subscriptions[$index])) { $subscriptions[$index] = ['channels' => [], 'queries' => []]; } - if (!\in_array($channel, $subscriptions[$index]['channels'], true)) { + if (! \in_array($channel, $subscriptions[$index]['channels'], true)) { $subscriptions[$index]['channels'][] = $channel; } @@ -677,8 +496,9 @@ class Realtime extends MessagingAdapter /** * Converts the queries from the Query Params into an array. - * @param array|string $queries - * @return array + * + * @param array|string $queries + * * @throws QueryException */ public static function convertQueries(mixed $queries): array @@ -687,11 +507,11 @@ class Realtime extends MessagingAdapter $stack = $queries; $allowed = implode(', ', RuntimeQuery::ALLOWED_QUERIES); - while (!empty($stack)) { + while (! empty($stack)) { $query = array_pop($stack); $method = $query->getMethod(); - if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { + if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { throw new QueryException( "Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}" ); @@ -712,13 +532,6 @@ class Realtime extends MessagingAdapter /** * Create channels array based on the event name and payload. * - * @param string $event - * @param Document $payload - * @param Document|null $project - * @param Document|null $database - * @param Document|null $collection - * @param Document|null $bucket - * @return array * @throws \Exception */ public static function fromPayload(string $event, Document $payload, ?Document $project = null, ?Document $database = null, ?Document $collection = null, ?Document $bucket = null): array @@ -733,19 +546,19 @@ class Realtime extends MessagingAdapter switch ($parts[0]) { case 'users': $channels[] = 'account'; - $channels[] = 'account.' . $parts[1]; + $channels[] = 'account.'.$parts[1]; $roles = [Role::user(ID::custom($parts[1]))->toString()]; break; case 'rules': case 'migrations': $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; case 'projects': $channels[] = 'console'; - $channels[] = 'projects.' . $parts[1]; + $channels[] = 'projects.'.$parts[1]; $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; @@ -753,11 +566,11 @@ class Realtime extends MessagingAdapter if ($parts[2] === 'memberships') { $permissionsChanged = $parts[4] ?? false; $channels[] = 'memberships'; - $channels[] = 'memberships.' . $parts[3]; + $channels[] = 'memberships.'.$parts[3]; } else { $permissionsChanged = $parts[2] === 'create'; $channels[] = 'teams'; - $channels[] = 'teams.' . $parts[1]; + $channels[] = 'teams.'.$parts[1]; } $roles = [Role::team(ID::custom($parts[1]))->toString()]; break; @@ -768,7 +581,7 @@ class Realtime extends MessagingAdapter $resource = $parts[4] ?? ''; if (in_array($resource, ['columns', 'attributes', 'indexes'])) { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } elseif (in_array($resource, ['rows', 'documents'])) { @@ -810,8 +623,8 @@ class Realtime extends MessagingAdapter throw new \Exception('Bucket needs to be passed to Realtime for File events in the Storage.'); } $channels[] = 'files'; - $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files'; - $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.' . $payload->getId(); + $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files'; + $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files.'.$payload->getId(); $roles = $bucket->getAttribute('fileSecurity', false) ? \array_merge($bucket->getRead(), $payload->getRead()) @@ -821,17 +634,17 @@ class Realtime extends MessagingAdapter break; case 'functions': if ($parts[2] === 'executions') { - if (!empty($payload->getRead())) { + if (! empty($payload->getRead())) { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $channels[] = 'executions'; - $channels[] = 'executions.' . $payload->getId(); - $channels[] = 'functions.' . $payload->getAttribute('functionId'); + $channels[] = 'executions.'.$payload->getId(); + $channels[] = 'functions.'.$payload->getAttribute('functionId'); $roles = $payload->getRead(); } } elseif ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } @@ -840,30 +653,55 @@ class Realtime extends MessagingAdapter case 'sites': if ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } break; } + // Action is the last segment of the event; for attribute-suffixed events + // it is second-to-last. + $count = \count($parts); + $action = null; + if ($count > 0 && \in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 1]; + } elseif ($count > 1 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 2]; + } + + if ($action !== null && ! empty($channels)) { + $augmented = $channels; + foreach ($channels as $channel) { + $segments = \explode('.', $channel); + $segCount = \count($segments); + $leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true); + $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true); + + if ($leafIsResource || $parentIsResource) { + $augmented[] = $channel.'.'.$action; + } + } + $channels = \array_values(\array_unique($augmented)); + } + return [ 'channels' => $channels, 'roles' => $roles, 'permissionsChanged' => $permissionsChanged, - 'projectId' => $projectId + '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 - * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) + * @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 + * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) * @return array Array of channel names */ private static function getDatabaseChannels( @@ -875,7 +713,7 @@ class Realtime extends MessagingAdapter ): array { $basePrefix = $prefixOverride ?: $type; - if (!$databaseId || !$resourceId || !$payloadId) { + if (! $databaseId || ! $resourceId || ! $payloadId) { return []; } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index c82a55e438..e101494f50 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } - public function testUser(): void + public function test_user(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ] - ] + ], + ], ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function testSubscribeUnionsChannelsAndRoles(): void + public function test_subscribe_unions_channels_and_roles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + public function test_unsubscribe_subscription_removes_only_one_subscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function testUnsubscribeSubscriptionIsIdempotent(): void + public function test_unsubscribe_subscription_is_idempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function testConvertChannelsGuest(): void + public function test_convert_channels_guest(): void { $user = new Document([ - '$id' => '' + '$id' => '', ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testConvertChannelsUser(): void + public function test_convert_channels_user(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator' - ] + 'moderator', + ], ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest' - ] - ] - ] + 'guest', + ], + ], + ], ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,7 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testFromPayloadPermissions(): void + public function test_from_payload_permissions(): void { /** * Test Collection Level Permissions @@ -460,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function testFromPayloadBucketLevelPermissions(): void + public function test_from_payload_bucket_level_permissions(): void { /** * Test Bucket Level Permissions @@ -510,7 +510,7 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true + 'fileSecurity' => true, ]) ); @@ -518,443 +518,179 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function testHasSubscriberIsActionChannelAware(): void + public function test_from_payload_emits_action_suffixed_channels(): void + { + $result = Realtime::fromPayload( + event: 'databases.database_id.collections.collection_id.documents.document_id.create', + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Base channels remain. + $this->assertContains('documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id', $result['channels']); + + // Action-suffixed variants are appended for every base channel. + $this->assertContains('documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id.create', $result['channels']); + + // No mismatched action suffixes leak in. + $this->assertNotContains('documents.update', $result['channels']); + $this->assertNotContains('documents.delete', $result['channels']); + } + + public function test_from_payload_emits_action_suffix_for_every_action(): void + { + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "databases.database_id.collections.collection_id.documents.document_id.{$action}", + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + $this->assertContains("documents.{$action}", $result['channels'], "documents.{$action} missing"); + $this->assertContains( + "databases.database_id.collections.collection_id.documents.document_id.{$action}", + $result['channels'], + "specific-doc {$action} channel missing" + ); + } + } + + public function test_from_payload_does_not_suffix_when_no_action(): void + { + // Synthetic event without an action segment: e.g. an attribute event whose + // last segment is not a known action and whose second-to-last segment is + // also not a known action. + $result = Realtime::fromPayload( + event: 'buckets.bucket_id.files.file_id.update', + payload: new Document([ + '$id' => ID::custom('file_id'), + 'bucketId' => 'bucket_id', + '$permissions' => [Permission::read(Role::any())], + ]), + bucket: new Document([ + '$id' => ID::custom('bucket_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Action-suffixed variants for the file event. + $this->assertContains('files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id.update', $result['channels']); + + // Base channels remain. + $this->assertContains('files', $result['channels']); + $this->assertContains('buckets.bucket_id.files', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); + } + + public function test_from_payload_does_not_suffix_admin_channels(): void + { + // Function execution event emits resource-leaf channels (executions / functions) + // alongside admin channels (console / projects.X). Admin channels must NOT + // get an action suffix — only the resource-leaf channels do. + $result = Realtime::fromPayload( + event: 'functions.function_id.executions.execution_id.create', + payload: new Document([ + '$id' => ID::custom('execution_id'), + 'functionId' => 'function_id', + '$read' => [Role::any()->toString()], + '$permissions' => [Permission::read(Role::any())], + ]), + project: new Document([ + '$id' => ID::custom('project_id'), + 'teamId' => '123abc', + ]) + ); + + // Resource-leaf channels are suffixed. + $this->assertContains('executions', $result['channels']); + $this->assertContains('executions.create', $result['channels']); + $this->assertContains('executions.execution_id', $result['channels']); + $this->assertContains('executions.execution_id.create', $result['channels']); + $this->assertContains('functions.function_id', $result['channels']); + $this->assertContains('functions.function_id.create', $result['channels']); + + // Admin channels are NOT suffixed. + $this->assertContains('console', $result['channels']); + $this->assertNotContains('console.create', $result['channels']); + $this->assertContains('projects.project_id', $result['channels']); + $this->assertNotContains('projects.project_id.create', $result['channels']); + } + + public function test_action_suffix_delivers_only_matching_action_end_to_end(): void { $realtime = new Realtime(); - $realtime->subscribe( - '1', - 1, - 'sub-create', - [Role::any()->toString()], - ['documents.create'], - ); - - // Plain base lookup hits the subscription. - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); - - // Action-channel lookup matches when the action is in the stored list. - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); - - // Action-channel lookup misses when the action is not stored — even though - // the base channel exists. - $this->assertFalse($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); - - // Unknown project / role still resolves to false. - $this->assertFalse($realtime->hasSubscriber('nope', Role::any()->toString(), 'documents.create')); - $this->assertFalse($realtime->hasSubscriber('1', 'role:other', 'documents.create')); - - // No-channel form still works. - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString())); - } - - public function testHasSubscriberWildcardActionsSubsumeSpecific(): void - { - $realtime = new Realtime(); - - // Subscribing to plain `documents` stores actions = ['*']. Any action-channel - // lookup against the same base must succeed because '*' subsumes specific actions. - $realtime->subscribe( - '1', - 1, - 'sub-all', - [Role::any()->toString()], - ['documents'], - ); - - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.delete')); - } - - public function testParseActionChannel(): void - { - $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); - $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); - $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); - $this->assertSame(['documents', 'delete'], Realtime::parseActionChannel('documents.delete')); - $this->assertSame( - ['databases.X.collections.Y.documents.Z', 'create'], - Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') - ); - $this->assertSame( - ['databases.X.collections.Y.documents.Z', 'delete'], - Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.delete') - ); - - // No action suffix → unchanged with '*' default. - $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); - $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); - - // Unrecognised suffix → unchanged (treated as literal channel name). - $this->assertSame(['documents.bogus', '*'], Realtime::parseActionChannel('documents.bogus')); - } - - public function testActionChannelFiltersByEventAction(): void - { - $realtime = new Realtime(); - - // Two subscriptions on the same connection: one filtered to creates only, - // one filtered to updates only. - $realtime->subscribe( - '1', - 1, - 'sub-create', - [Role::any()->toString()], - ['documents.create'], - ); - $realtime->subscribe( - '1', - 1, - 'sub-update', - [Role::any()->toString()], - ['documents.update'], - ); + // Subscriber A scopes to creates; Subscriber B scopes to deletes. + $realtime->subscribe('1', 1, 'sub-create', [Role::any()->toString()], ['documents.create']); + $realtime->subscribe('1', 2, 'sub-delete', [Role::any()->toString()], ['documents.delete']); + // Simulate what fromPayload would publish for a create event. $createEvent = [ 'project' => '1', 'roles' => [Role::any()->toString()], 'data' => [ - 'channels' => ['documents'], - 'events' => [ - 'databases.db.collections.col.documents.doc.create', - 'databases.*.collections.*.documents.*.create', - ], + 'channels' => ['documents', 'documents.create'], 'payload' => ['$id' => 'doc'], ], ]; + $createReceivers = $realtime->getSubscribers($createEvent); + $this->assertArrayHasKey(1, $createReceivers); + $this->assertArrayNotHasKey(2, $createReceivers); - $updateEvent = $createEvent; - $updateEvent['data']['events'] = [ - 'databases.db.collections.col.documents.doc.update', - 'databases.*.collections.*.documents.*.update', - ]; - - // Create event should only deliver to sub-create. - $receivers = $realtime->getSubscribers($createEvent); - $this->assertCount(1, $receivers); - $this->assertArrayHasKey(1, $receivers); - $this->assertArrayHasKey('sub-create', $receivers[1]); - $this->assertArrayNotHasKey('sub-update', $receivers[1]); - - // Update event should only deliver to sub-update. - $receivers = $realtime->getSubscribers($updateEvent); - $this->assertCount(1, $receivers); - $this->assertArrayHasKey('sub-update', $receivers[1]); - $this->assertArrayNotHasKey('sub-create', $receivers[1]); - } - - public function testActionChannelDeleteFilter(): void - { - $realtime = new Realtime(); - - $realtime->subscribe( - '1', - 1, - 'sub-delete', - [Role::any()->toString()], - ['documents.delete'], - ); - + // Delete event. $deleteEvent = [ 'project' => '1', 'roles' => [Role::any()->toString()], 'data' => [ - 'channels' => ['documents'], - 'events' => [ - 'databases.db.collections.col.documents.doc.delete', - 'databases.*.collections.*.documents.*.delete', - ], + 'channels' => ['documents', 'documents.delete'], 'payload' => ['$id' => 'doc'], ], ]; - - $receivers = $realtime->getSubscribers($deleteEvent); - $this->assertArrayHasKey(1, $receivers); - $this->assertArrayHasKey('sub-delete', $receivers[1]); - - // Other actions on the same base channel should not match the delete filter. - $createEvent = $deleteEvent; - $createEvent['data']['events'] = [ - 'databases.db.collections.col.documents.doc.create', - 'databases.*.collections.*.documents.*.create', - ]; - $this->assertEmpty($realtime->getSubscribers($createEvent)); + $deleteReceivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(2, $deleteReceivers); + $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function testActionChannelHonorsResourceId(): void + public function test_plain_channel_still_receives_all_actions_end_to_end(): void { $realtime = new Realtime(); - // Subscribe to creates on a specific document only. - $realtime->subscribe( - '1', - 1, - 'sub-doc-create', - [Role::any()->toString()], - ['documents.789.create'], - ); + $realtime->subscribe('1', 1, 'sub-all', [Role::any()->toString()], ['documents']); - // The base channel for `documents.789.create` is `documents.789`. - $event = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents.789'], - 'events' => [ - 'databases.db.collections.col.documents.789.create', - 'databases.*.collections.*.documents.*.create', + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', "documents.{$action}"], + 'payload' => ['$id' => 'doc'], ], - 'payload' => ['$id' => '789'], - ], - ]; - - $receivers = $realtime->getSubscribers($event); - $this->assertCount(1, $receivers); - $this->assertArrayHasKey('sub-doc-create', $receivers[1]); - - // Update on the same document should not match. - $event['data']['events'] = [ - 'databases.db.collections.col.documents.789.update', - 'databases.*.collections.*.documents.*.update', - ]; - - $this->assertEmpty($realtime->getSubscribers($event)); - - // Create on a different document should not match (different base channel - // entirely; subscription tree key won't even line up). - $event['data']['channels'] = ['documents.999']; - $event['data']['events'] = [ - 'databases.db.collections.col.documents.999.create', - 'databases.*.collections.*.documents.*.create', - ]; - - $this->assertEmpty($realtime->getSubscribers($event)); - } - - public function testNonActionChannelStillReceivesAllEvents(): void - { - $realtime = new Realtime(); - - $realtime->subscribe( - '1', - 1, - 'sub-all', - [Role::any()->toString()], - ['documents'], - ); - - $event = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => [ - 'databases.db.collections.col.documents.doc.create', - ], - 'payload' => ['$id' => 'doc'], - ], - ]; - - $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); - - $event['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); - - $event['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); - } - - public function testMixedActionAndBaseChannelInSameSubscription(): void - { - $realtime = new Realtime(); - - // Same sub-id covers `documents.create` (filtered) and `files` (unfiltered). - // After parsing they live under different base-channel keys with their own - // action metadata, so each gets its own filter behaviour. - $realtime->subscribe( - '1', - 1, - 'sub-mixed', - [Role::any()->toString()], - ['documents.create', 'files'], - ); - - // Create event on documents → matches. - $createDoc = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.create'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($createDoc)); - - // Update event on documents → blocked by the action filter on the documents key. - $updateDoc = $createDoc; - $updateDoc['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertEmpty($realtime->getSubscribers($updateDoc)); - - // Files channel has no action filter — any action delivers. - $updateFile = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['files'], - 'events' => ['buckets.bucket.files.file.update'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateFile)); - } - - public function testActionChannelMetadataRoundTrips(): void - { - $realtime = new Realtime(); - - $realtime->subscribe( - '1', - 1, - 'sub-create', - [Role::any()->toString()], - ['documents.create', 'files'], - ); - - $meta = $realtime->getSubscriptionMetadata(1); - - $this->assertArrayHasKey('sub-create', $meta); - $this->assertContains('documents.create', $meta['sub-create']['channels']); - $this->assertContains('files', $meta['sub-create']['channels']); - // Base form should NOT leak when an action was set. - $this->assertNotContains('documents', $meta['sub-create']['channels']); - } - - public function testSubscribeWithSameSubIdReplacesActionsNotMerges(): void - { - $realtime = new Realtime(); - $role = Role::any()->toString(); - - // Initial subscribe: only `create` events on the documents base. - $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.create']); - - $createEvent = [ - 'project' => '1', - 'roles' => [$role], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.create'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); - - // Re-subscribe with the SAME sub-id but a different action. Per the upsert - // contract documented on Realtime::subscribe, this fully replaces the prior - // state — actions are NOT unioned across calls (channels and queries already - // followed replace-not-merge semantics; actions match that rule). - $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.update']); - - // Create no longer matches: previous filter is gone. - $this->assertEmpty($realtime->getSubscribers($createEvent)); - - // Update now matches. - $updateEvent = $createEvent; - $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); - - // Metadata reflects only the new state. - $meta = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.update', $meta['sub-x']['channels']); - $this->assertNotContains('documents.create', $meta['sub-x']['channels']); - } - - public function testActionAndBaseChannelTogetherRoundTripsLosslessly(): void - { - $realtime = new Realtime(); - - // Subscribing with both a specific-action channel AND its plain base form must - // preserve both names: '*' short-circuits delivery (so update events still - // come through), but the metadata kept for re-auth/permissions-changed flows - // would otherwise drop `documents.create` entirely on the next refresh. - $realtime->subscribe( - '1', - 1, - 'sub-mixed', - [Role::any()->toString()], - ['documents.create', 'documents'], - ); - - $meta = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.create', $meta['sub-mixed']['channels']); - $this->assertContains('documents', $meta['sub-mixed']['channels']); - - // Update events still deliver because '*' is in the actions list. - $updateEvent = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.update'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); - - // Round-trip: feed the metadata back through subscribe() and the original - // pair of channel names must come out again. - $realtime->unsubscribe(1); - $realtime->subscribe( - '1', - 1, - 'sub-mixed', - [Role::any()->toString()], - $meta['sub-mixed']['channels'], - ); - - $metaAgain = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.create', $metaAgain['sub-mixed']['channels']); - $this->assertContains('documents', $metaAgain['sub-mixed']['channels']); - } - - public function testMergingMultipleActionsOnSameBaseChannel(): void - { - $realtime = new Realtime(); - - // Subscribing to multiple actions on the same base merges their action lists - // onto a single tree entry. - $realtime->subscribe( - '1', - 1, - 'sub-multi', - [Role::any()->toString()], - ['documents.create', 'documents.update'], - ); - - $createEvent = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.create'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); - - $updateEvent = $createEvent; - $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); - - // Upsert should not match — neither create nor update covers it. - $upsertEvent = $createEvent; - $upsertEvent['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; - $this->assertEmpty($realtime->getSubscribers($upsertEvent)); - - $meta = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.create', $meta['sub-multi']['channels']); - $this->assertContains('documents.update', $meta['sub-multi']['channels']); + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); + } } } From d25ccb784da6b33bfd24cb683c48e32a3ba1a610 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 15:59:34 +0530 Subject: [PATCH 07/56] refactor(Realtime): remove SUPPORTED_ACTIONS constant and simplify action extraction logic --- src/Appwrite/Messaging/Adapter/Realtime.php | 15 +---- tests/unit/Messaging/MessagingTest.php | 68 ++++++++++----------- 2 files changed, 37 insertions(+), 46 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7be0911b8c..337a99af50 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,8 +14,6 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { - public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; - private const RESOURCE_LEAF_NAMES = [ 'documents', 'rows', @@ -660,17 +658,10 @@ class Realtime extends MessagingAdapter break; } - // Action is the last segment of the event; for attribute-suffixed events - // it is second-to-last. - $count = \count($parts); - $action = null; - if ($count > 0 && \in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { - $action = $parts[$count - 1]; - } elseif ($count > 1 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { - $action = $parts[$count - 2]; - } - if ($action !== null && ! empty($channels)) { + if (! empty($channels)) { + // create, update, upsert, delete + $action = $parts[\count($parts) - 1]; $augmented = $channels; foreach ($channels as $channel) { $segments = \explode('.', $channel); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index e101494f50..9230423727 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - protected function setUp(): void + public function setUp(): void { } - protected function tearDown(): void + public function tearDown(): void { } - public function test_user(): void + public function testUser(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ], - ], + ] + ] ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function test_subscribe_unions_channels_and_roles(): void + public function testSubscribeUnionsChannelsAndRoles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function test_unsubscribe_subscription_removes_only_one_subscription(): void + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function test_unsubscribe_subscription_is_idempotent(): void + public function testUnsubscribeSubscriptionIsIdempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function test_convert_channels_guest(): void + public function testConvertChannelsGuest(): void { $user = new Document([ - '$id' => '', + '$id' => '' ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_convert_channels_user(): void + public function testConvertChannelsUser(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator', - ], + 'moderator' + ] ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest', - ], - ], - ], + 'guest' + ] + ] + ] ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,7 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_from_payload_permissions(): void + public function testFromPayloadPermissions(): void { /** * Test Collection Level Permissions @@ -460,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function test_from_payload_bucket_level_permissions(): void + public function testFromPayloadBucketLevelPermissions(): void { /** * Test Bucket Level Permissions @@ -510,15 +510,15 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true, + 'fileSecurity' => true ]) ); $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - - public function test_from_payload_emits_action_suffixed_channels(): void + + public function testFromPayloadEmitsActionSuffixedChannels(): void { $result = Realtime::fromPayload( event: 'databases.database_id.collections.collection_id.documents.document_id.create', @@ -550,7 +550,7 @@ class MessagingTest extends TestCase $this->assertNotContains('documents.delete', $result['channels']); } - public function test_from_payload_emits_action_suffix_for_every_action(): void + public function testFromPayloadEmitsActionSuffixForEveryAction(): void { foreach (['create', 'update', 'upsert', 'delete'] as $action) { $result = Realtime::fromPayload( @@ -577,7 +577,7 @@ class MessagingTest extends TestCase } } - public function test_from_payload_does_not_suffix_when_no_action(): void + public function testFromPayloadDoesNotSuffixWhenNoAction(): void { // Synthetic event without an action segment: e.g. an attribute event whose // last segment is not a known action and whose second-to-last segment is @@ -606,7 +606,7 @@ class MessagingTest extends TestCase $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); } - public function test_from_payload_does_not_suffix_admin_channels(): void + public function testFromPayloadDoesNotSuffixAdminChannels(): void { // Function execution event emits resource-leaf channels (executions / functions) // alongside admin channels (console / projects.X). Admin channels must NOT @@ -640,7 +640,7 @@ class MessagingTest extends TestCase $this->assertNotContains('projects.project_id.create', $result['channels']); } - public function test_action_suffix_delivers_only_matching_action_end_to_end(): void + public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void { $realtime = new Realtime(); @@ -675,7 +675,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function test_plain_channel_still_receives_all_actions_end_to_end(): void + public function testPlainChannelStillReceivesAllActionsEndToEnd(): void { $realtime = new Realtime(); @@ -693,4 +693,4 @@ class MessagingTest extends TestCase $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); } } -} +} \ No newline at end of file From e6d5c216ebe219865bfc3e367dd6204ed917c051 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 16:06:00 +0530 Subject: [PATCH 08/56] refactor(Realtime): update action extraction logic and enhance test method naming conventions --- src/Appwrite/Messaging/Adapter/Realtime.php | 17 ++- tests/unit/Messaging/MessagingTest.php | 112 ++++++++++++++------ 2 files changed, 92 insertions(+), 37 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 337a99af50..73bcc6e088 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,6 +14,8 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + private const RESOURCE_LEAF_NAMES = [ 'documents', 'rows', @@ -658,10 +660,19 @@ class Realtime extends MessagingAdapter break; } + // Action is the last segment for plain CRUD events (e.g. `documents.X.create`), + // and the second-to-last segment for attribute-trailing events + // (e.g. `users.U.update.email`, `teams.T.update.prefs`, + // `teams.T.memberships.M.update.status`). Without the second-to-last fallback + $count = \count($parts); + $action = null; + if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 1]; + } elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 2]; + } - if (! empty($channels)) { - // create, update, upsert, delete - $action = $parts[\count($parts) - 1]; + if ($action !== null && ! empty($channels)) { $augmented = $channels; foreach ($channels as $channel) { $segments = \explode('.', $channel); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 9230423727..9abe71c890 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } - public function testUser(): void + public function test_user(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ] - ] + ], + ], ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function testSubscribeUnionsChannelsAndRoles(): void + public function test_subscribe_unions_channels_and_roles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + public function test_unsubscribe_subscription_removes_only_one_subscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function testUnsubscribeSubscriptionIsIdempotent(): void + public function test_unsubscribe_subscription_is_idempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function testConvertChannelsGuest(): void + public function test_convert_channels_guest(): void { $user = new Document([ - '$id' => '' + '$id' => '', ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testConvertChannelsUser(): void + public function test_convert_channels_user(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator' - ] + 'moderator', + ], ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest' - ] - ] - ] + 'guest', + ], + ], + ], ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,7 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testFromPayloadPermissions(): void + public function test_from_payload_permissions(): void { /** * Test Collection Level Permissions @@ -460,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function testFromPayloadBucketLevelPermissions(): void + public function test_from_payload_bucket_level_permissions(): void { /** * Test Bucket Level Permissions @@ -510,15 +510,15 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true + 'fileSecurity' => true, ]) ); $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - - public function testFromPayloadEmitsActionSuffixedChannels(): void + + public function test_from_payload_emits_action_suffixed_channels(): void { $result = Realtime::fromPayload( event: 'databases.database_id.collections.collection_id.documents.document_id.create', @@ -550,7 +550,7 @@ class MessagingTest extends TestCase $this->assertNotContains('documents.delete', $result['channels']); } - public function testFromPayloadEmitsActionSuffixForEveryAction(): void + public function test_from_payload_emits_action_suffix_for_every_action(): void { foreach (['create', 'update', 'upsert', 'delete'] as $action) { $result = Realtime::fromPayload( @@ -577,7 +577,7 @@ class MessagingTest extends TestCase } } - public function testFromPayloadDoesNotSuffixWhenNoAction(): void + public function test_from_payload_does_not_suffix_when_no_action(): void { // Synthetic event without an action segment: e.g. an attribute event whose // last segment is not a known action and whose second-to-last segment is @@ -606,7 +606,7 @@ class MessagingTest extends TestCase $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); } - public function testFromPayloadDoesNotSuffixAdminChannels(): void + public function test_from_payload_does_not_suffix_admin_channels(): void { // Function execution event emits resource-leaf channels (executions / functions) // alongside admin channels (console / projects.X). Admin channels must NOT @@ -640,7 +640,51 @@ class MessagingTest extends TestCase $this->assertNotContains('projects.project_id.create', $result['channels']); } - public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void + public function test_from_payload_handles_attribute_trailing_action_events(): void + { + // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the + // second-to-last segment, not the last one. The suffix must still be `.update`. + $userResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + + $this->assertContains('account', $userResult['channels']); + $this->assertContains('account.user_id', $userResult['channels']); + $this->assertContains('account.update', $userResult['channels']); + $this->assertContains('account.user_id.update', $userResult['channels']); + // The attribute name must NOT leak into the channel namespace. + $this->assertNotContains('account.email', $userResult['channels']); + $this->assertNotContains('account.user_id.email', $userResult['channels']); + + // `teams.[teamId].update.prefs` — same shape at the team level. + $teamResult = Realtime::fromPayload( + event: 'teams.team_id.update.prefs', + payload: new Document(['$id' => ID::custom('team_id')]) + ); + + $this->assertContains('teams', $teamResult['channels']); + $this->assertContains('teams.team_id', $teamResult['channels']); + $this->assertContains('teams.update', $teamResult['channels']); + $this->assertContains('teams.team_id.update', $teamResult['channels']); + $this->assertNotContains('teams.prefs', $teamResult['channels']); + $this->assertNotContains('teams.team_id.prefs', $teamResult['channels']); + + // `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper. + $membershipResult = Realtime::fromPayload( + event: 'teams.team_id.memberships.membership_id.update.status', + payload: new Document(['$id' => ID::custom('membership_id')]) + ); + + $this->assertContains('memberships', $membershipResult['channels']); + $this->assertContains('memberships.membership_id', $membershipResult['channels']); + $this->assertContains('memberships.update', $membershipResult['channels']); + $this->assertContains('memberships.membership_id.update', $membershipResult['channels']); + $this->assertNotContains('memberships.status', $membershipResult['channels']); + $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); + } + + public function test_action_suffix_delivers_only_matching_action_end_to_end(): void { $realtime = new Realtime(); @@ -675,7 +719,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function testPlainChannelStillReceivesAllActionsEndToEnd(): void + public function test_plain_channel_still_receives_all_actions_end_to_end(): void { $realtime = new Realtime(); @@ -693,4 +737,4 @@ class MessagingTest extends TestCase $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); } } -} \ No newline at end of file +} From 340ce9d56b5ac985c729cd9ca181fde7b31fe031 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 16:40:15 +0530 Subject: [PATCH 09/56] Add tests for channel conversion and event handling in Messaging - Implement `test_convert_channels_rewrites_account_action_suffixes` to ensure that account action suffixes are correctly rewritten to user-scoped channels. - Add `test_convert_channels_drops_account_actions_for_guest` to verify that account actions are dropped for guests without a user ID. - Introduce `test_from_payload_does_not_suffix_account_for_nested_user_events` to confirm that nested user events do not leak action suffixes onto account channels. --- src/Appwrite/Messaging/Adapter/Realtime.php | 40 +++- .../Realtime/RealtimeCustomClientTest.php | 191 +++++++++++------- tests/unit/Messaging/MessagingTest.php | 89 ++++++++ 3 files changed, 239 insertions(+), 81 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 73bcc6e088..ee2dd5fe13 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -399,7 +399,11 @@ class Realtime extends MessagingAdapter /** * Converts the channels from the Query Params into an array. - * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. + * Also renames the account channel to account.USER_ID, rewrites action-suffixed + * account variants (`account.create`, `account.update`, `account.upsert`, + * `account.delete`) to `account.USER_ID.{action}` so they match the channels + * fromPayload() publishes for top-level user events, and removes all other + * illegal account channel variations (e.g. another user's `account.{otherId}`). */ public static function convertChannels(array $channels, string $userId): array { @@ -407,15 +411,26 @@ class Realtime extends MessagingAdapter foreach ($channels as $key => $value) { switch (true) { - case str_starts_with($key, 'account.'): - unset($channels[$key]); - break; - case $key === 'account': if (! empty($userId)) { $channels['account.'.$userId] = $value; } break; + + case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'): + // Translate `account.{action}` into the user-scoped `account.{userId}.{action}` + // so a subscriber only receives their own account events. Without the rewrite + // the literal `account.{action}` channel would match every user's events. + unset($channels[$key]); + if (! empty($userId)) { + $action = \substr($key, \strlen('account.')); + $channels['account.'.$userId.'.'.$action] = $value; + } + break; + + case str_starts_with($key, 'account.'): + unset($channels[$key]); + break; } } @@ -672,6 +687,21 @@ class Realtime extends MessagingAdapter $action = $parts[$count - 2]; } + // The `users` branch emits only user-level account channels + // (`account`, `account.{userId}`) regardless of event depth, so nested events + // like `users.U.sessions.S.create` or `users.U.challenges.C.create` would + // otherwise be suffixed as `account.create` — making a subscription to + // `account.create` receive unrelated session/challenge/recovery/verification + // events. Restrict suffixing to top-level user events where the action sits + // at parts[2] (`users.U.create`, `users.U.update.email`, etc.). + if ( + $action !== null + && ($parts[0] ?? null) === 'users' + && ($parts[2] ?? null) !== $action + ) { + $action = null; + } + if ($action !== null && ! empty($channels)) { $augmented = $channels; foreach ($channels as $channel) { diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ef1c5fce7a..4960f05147 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -335,10 +335,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.name", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -368,10 +370,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.password", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -401,10 +405,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.email", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -432,11 +438,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertArrayNotHasKey('secret', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.create', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']); @@ -475,10 +483,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']); @@ -510,10 +520,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.prefs", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -551,10 +563,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.create', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']); @@ -583,10 +597,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.delete', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -620,10 +636,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.delete', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -661,10 +679,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.create', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']); @@ -695,10 +715,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']); @@ -820,7 +842,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -865,7 +887,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -921,7 +943,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -977,7 +999,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1009,7 +1031,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1058,7 +1080,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1086,7 +1108,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1114,7 +1136,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1151,7 +1173,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1180,7 +1202,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1209,7 +1231,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1256,7 +1278,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1435,7 +1457,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']); @@ -1466,7 +1488,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']); @@ -1516,7 +1538,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1570,7 +1592,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1623,7 +1645,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1650,7 +1672,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1689,7 +1711,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']); @@ -1720,7 +1742,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']); @@ -1773,7 +1795,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1811,7 +1833,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1953,7 +1975,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -1992,7 +2014,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2042,7 +2064,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2130,10 +2152,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.create', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.create", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.create", $response['data']['events']); @@ -2169,10 +2194,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.update', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.update", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.update", $response['data']['events']); @@ -2200,10 +2228,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.delete', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.delete", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.delete", $response['data']['events']); @@ -2320,7 +2351,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(5, $response['data']['channels']); + $this->assertCount(8, $response['data']['channels']); $this->assertContains('console', $response['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $response['data']['channels']); @@ -2343,7 +2374,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $responseUpdate['type']); $this->assertNotEmpty($responseUpdate['data']); $this->assertArrayHasKey('timestamp', $responseUpdate['data']); - $this->assertCount(5, $responseUpdate['data']['channels']); + $this->assertCount(8, $responseUpdate['data']['channels']); $this->assertContains('console', $responseUpdate['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $responseUpdate['data']['channels']); @@ -2418,9 +2449,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.create', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.create", $response['data']['channels']); $this->assertContains("teams.{$teamId}.create", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.create", $response['data']['events']); @@ -2447,9 +2480,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.update", $response['data']['events']); @@ -2480,9 +2515,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.update.prefs", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); @@ -2547,9 +2584,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('memberships', $response['data']['channels']); $this->assertContains("memberships.{$membershipId}", $response['data']['channels']); + $this->assertContains('memberships.update', $response['data']['channels']); + $this->assertContains("memberships.{$membershipId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.*.update", $response['data']['events']); @@ -4276,7 +4315,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4333,7 +4372,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -4401,7 +4440,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('rows', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); @@ -4472,7 +4511,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4518,7 +4557,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4582,7 +4621,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4624,7 +4663,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4666,7 +4705,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4717,7 +4756,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4760,7 +4799,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4789,7 +4828,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4836,7 +4875,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -4957,7 +4996,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4992,7 +5031,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5036,7 +5075,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5080,7 +5119,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5098,7 +5137,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5133,7 +5172,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5161,7 +5200,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5189,7 +5228,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5226,7 +5265,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5255,7 +5294,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5284,7 +5323,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5331,7 +5370,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -5436,7 +5475,7 @@ class RealtimeCustomClientTest extends Scope $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); // vectorsdb channels should include 3 items like documentsdb - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5467,7 +5506,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); @@ -5486,7 +5525,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5525,7 +5564,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']); @@ -5540,7 +5579,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $client->close(); @@ -5643,7 +5682,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); @@ -5674,7 +5713,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 9abe71c890..d66a86a4f1 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -396,6 +396,56 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } + public function test_convert_channels_rewrites_account_action_suffixes(): void + { + // A subscriber to `account.{action}` should receive the user-scoped + // `account.{userId}.{action}` channel that fromPayload publishes for + // top-level user events. Without the rewrite the channel would either be + // stripped (security guard against subscribing to other users' account) + // or, if left literal, leak every user's account events to this client. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete'], + '123', + ); + + $this->assertArrayHasKey('account.123.create', $channels); + $this->assertArrayHasKey('account.123.update', $channels); + $this->assertArrayHasKey('account.123.upsert', $channels); + $this->assertArrayHasKey('account.123.delete', $channels); + + // The literal forms must not survive — they would otherwise match every + // user's events, not just the subscribed user's. + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayNotHasKey('account.upsert', $channels); + $this->assertArrayNotHasKey('account.delete', $channels); + + // Other-user channels and unknown action-like suffixes still get stripped. + $channels = Realtime::convertChannels( + ['account.other_id', 'account.bogus', 'account.123', 'account.create'], + '123', + ); + $this->assertArrayNotHasKey('account.other_id', $channels); + $this->assertArrayNotHasKey('account.bogus', $channels); + $this->assertArrayNotHasKey('account.123', $channels); + $this->assertArrayHasKey('account.123.create', $channels); + } + + public function test_convert_channels_drops_account_actions_for_guest(): void + { + // No userId → no place to scope the action-suffixed channel, so the + // action-suffixed forms are dropped entirely. Plain `account` survives + // (matching existing guest behavior — see test_convert_channels_guest). + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account'], + '', + ); + + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayHasKey('account', $channels); + } + public function test_from_payload_permissions(): void { /** @@ -684,6 +734,45 @@ class MessagingTest extends TestCase $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); } + public function test_from_payload_does_not_suffix_account_for_nested_user_events(): void + { + // Nested user events (challenges/sessions/recovery/verification) emit only + // user-level account channels in fromPayload. The trailing action belongs to + // the nested resource, NOT to the user account. A subscriber to + // `account.create` must not receive `users.U.challenges.C.create` or + // `users.U.sessions.S.delete` events — that would silently leak unrelated + // MFA / session traffic into account-level filters. + foreach (['challenges', 'sessions', 'recovery', 'verification'] as $sub) { + foreach (['create', 'update', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "users.user_id.{$sub}.sub_id.{$action}", + payload: new Document(['$id' => ID::custom('sub_id')]) + ); + + $this->assertContains('account', $result['channels'], "{$sub}.{$action} should still emit base account channel"); + $this->assertContains('account.user_id', $result['channels'], "{$sub}.{$action} should still emit user-scoped account channel"); + $this->assertNotContains("account.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto account channel"); + $this->assertNotContains("account.user_id.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto user-scoped account channel"); + } + } + + // Top-level user events SHOULD still suffix — guard against an over-eager fix + // that suppresses the suffix for legitimate account-level CRUD. + $createResult = Realtime::fromPayload( + event: 'users.user_id.create', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.create', $createResult['channels']); + $this->assertContains('account.user_id.create', $createResult['channels']); + + $updateResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.update', $updateResult['channels']); + $this->assertContains('account.user_id.update', $updateResult['channels']); + } + public function test_action_suffix_delivers_only_matching_action_end_to_end(): void { $realtime = new Realtime(); From 1928605bd995afa3761ce6d67a55fe0da9977851 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 16:42:45 +0530 Subject: [PATCH 10/56] linting --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index ee2dd5fe13..7085e2062b 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -696,7 +696,7 @@ class Realtime extends MessagingAdapter // at parts[2] (`users.U.create`, `users.U.update.email`, etc.). if ( $action !== null - && ($parts[0] ?? null) === 'users' + && $parts[0] === 'users' && ($parts[2] ?? null) !== $action ) { $action = null; From ef4b9c49346019fa304d9fba69bbe114134e04e3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:16:17 +0530 Subject: [PATCH 11/56] updated --- .../Realtime/RealtimeCustomClientTest.php | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 4960f05147..813ef70ff0 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -438,13 +438,14 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (verification) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertArrayNotHasKey('secret', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.create', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']); @@ -483,12 +484,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (verification) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.update', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']); @@ -563,12 +565,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (sessions) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.create', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']); @@ -597,12 +600,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (sessions) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.delete', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -636,12 +640,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (sessions) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.delete', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -679,12 +684,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (recovery) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.create', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']); @@ -715,12 +721,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (recovery) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.update', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']); From 7e3114d733a1c77529eb645e40b3312bb26503c2 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:26:27 +0530 Subject: [PATCH 12/56] linting --- src/Appwrite/Messaging/Adapter/Realtime.php | 95 ++++++++++----------- tests/unit/Messaging/MessagingTest.php | 2 +- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7085e2062b..7526df9dd0 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -87,14 +87,14 @@ class Realtime extends MessagingAdapter array $queryGroup = [], ?string $userId = null ): void { - if (! isset($this->subscriptions[$projectId])) { // Init Project + if (!isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; } $strings = []; $data = []; - if (! empty($channels)) { + if (!empty($channels)) { if (empty($queryGroup)) { $strings[] = Query::select(['*'])->toString(); } else { @@ -109,15 +109,15 @@ class Realtime extends MessagingAdapter } foreach ($roles as $role) { - if (! isset($this->subscriptions[$projectId][$role])) { + if (!isset($this->subscriptions[$projectId][$role])) { $this->subscriptions[$projectId][$role] = []; } foreach ($channels as $channel) { - if (! isset($this->subscriptions[$projectId][$role][$channel])) { + if (!isset($this->subscriptions[$projectId][$role][$channel])) { $this->subscriptions[$projectId][$role][$channel] = []; } - if (! isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { + if (!isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { $this->subscriptions[$projectId][$role][$channel][$identifier] = []; } $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $data; @@ -165,23 +165,23 @@ class Realtime extends MessagingAdapter // Extract subscription data from subscriptions tree foreach ($roles as $role) { - if (! isset($this->subscriptions[$projectId][$role])) { + if (!isset($this->subscriptions[$projectId][$role])) { continue; } foreach ($channels as $channel) { - if (! isset($this->subscriptions[$projectId][$role][$channel][$connection])) { + if (!isset($this->subscriptions[$projectId][$role][$channel][$connection])) { continue; } foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subscriptionId => $data) { - if (! isset($subscriptions[$subscriptionId])) { + if (!isset($subscriptions[$subscriptionId])) { $subscriptions[$subscriptionId] = [ 'channels' => [], 'queries' => $data['strings'] ?? [], ]; } - if (! \in_array($channel, $subscriptions[$subscriptionId]['channels'])) { + if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) { $subscriptions[$subscriptionId]['channels'][] = $channel; } } @@ -230,7 +230,7 @@ class Realtime extends MessagingAdapter public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool { $projectId = $this->connections[$connection]['projectId'] ?? ''; - if ($projectId === '' || ! isset($this->subscriptions[$projectId])) { + if ($projectId === '' || !isset($this->subscriptions[$projectId])) { return false; } @@ -238,7 +238,7 @@ class Realtime extends MessagingAdapter foreach ($this->subscriptions[$projectId] as $role => $byChannel) { foreach ($byChannel as $channel => $byConnection) { - if (! isset($byConnection[$connection][$subscriptionId])) { + if (!isset($byConnection[$connection][$subscriptionId])) { continue; } @@ -279,7 +279,7 @@ class Realtime extends MessagingAdapter */ private function recomputeConnectionState(mixed $connection): void { - if (! isset($this->connections[$connection])) { + if (!isset($this->connections[$connection])) { return; } @@ -311,7 +311,7 @@ class Realtime extends MessagingAdapter return array_key_exists($projectId, $this->subscriptions) && array_key_exists($role, $this->subscriptions[$projectId]) && array_key_exists($channel, $this->subscriptions[$projectId][$role]) - && ! empty($this->subscriptions[$projectId][$role][$channel]); + && !empty($this->subscriptions[$projectId][$role][$channel]); } /** @@ -357,7 +357,7 @@ class Realtime extends MessagingAdapter { $receivers = []; - if (! isset($this->subscriptions[$event['project']])) { + if (!isset($this->subscriptions[$event['project']])) { return $receivers; } @@ -367,7 +367,7 @@ class Realtime extends MessagingAdapter foreach ($event['data']['channels'] as $channel) { if ( ! \array_key_exists($channel, $subscriptionsByChannel) - || (! \in_array($role, $event['roles']) && ! \in_array(Role::any()->toString(), $event['roles'])) + || (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles'])) ) { continue; } @@ -384,8 +384,8 @@ class Realtime extends MessagingAdapter } } - if (! empty($matched)) { - if (! isset($receivers[$id])) { + if (!empty($matched)) { + if (!isset($receivers[$id])) { $receivers[$id] = []; } $receivers[$id] += $matched; @@ -412,7 +412,7 @@ class Realtime extends MessagingAdapter foreach ($channels as $key => $value) { switch (true) { case $key === 'account': - if (! empty($userId)) { + if (!empty($userId)) { $channels['account.'.$userId] = $value; } break; @@ -422,7 +422,7 @@ class Realtime extends MessagingAdapter // so a subscriber only receives their own account events. Without the rewrite // the literal `account.{action}` channel would match every user's events. unset($channels[$key]); - if (! empty($userId)) { + if (!empty($userId)) { $action = \substr($key, \strlen('account.')); $channels['account.'.$userId.'.'.$action] = $value; } @@ -475,7 +475,7 @@ class Realtime extends MessagingAdapter } if ($params === null) { - if (! isset($subscriptions[0])) { + if (!isset($subscriptions[0])) { $subscriptions[0] = ['channels' => [], 'queries' => []]; } $subscriptions[0]['channels'][] = $channel; @@ -491,11 +491,11 @@ class Realtime extends MessagingAdapter } foreach ($params as $index => $slot) { - if (! isset($subscriptions[$index])) { + if (!isset($subscriptions[$index])) { $subscriptions[$index] = ['channels' => [], 'queries' => []]; } - if (! \in_array($channel, $subscriptions[$index]['channels'], true)) { + if (!\in_array($channel, $subscriptions[$index]['channels'], true)) { $subscriptions[$index]['channels'][] = $channel; } @@ -522,7 +522,7 @@ class Realtime extends MessagingAdapter $stack = $queries; $allowed = implode(', ', RuntimeQuery::ALLOWED_QUERIES); - while (! empty($stack)) { + while (!empty($stack)) { $query = array_pop($stack); $method = $query->getMethod(); @@ -561,19 +561,19 @@ class Realtime extends MessagingAdapter switch ($parts[0]) { case 'users': $channels[] = 'account'; - $channels[] = 'account.'.$parts[1]; + $channels[] = 'account.' . $parts[1]; $roles = [Role::user(ID::custom($parts[1]))->toString()]; break; case 'rules': case 'migrations': $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; case 'projects': $channels[] = 'console'; - $channels[] = 'projects.'.$parts[1]; + $channels[] = 'projects.' . $parts[1]; $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; @@ -581,11 +581,11 @@ class Realtime extends MessagingAdapter if ($parts[2] === 'memberships') { $permissionsChanged = $parts[4] ?? false; $channels[] = 'memberships'; - $channels[] = 'memberships.'.$parts[3]; + $channels[] = 'memberships.' . $parts[3]; } else { $permissionsChanged = $parts[2] === 'create'; $channels[] = 'teams'; - $channels[] = 'teams.'.$parts[1]; + $channels[] = 'teams.' . $parts[1]; } $roles = [Role::team(ID::custom($parts[1]))->toString()]; break; @@ -596,7 +596,7 @@ class Realtime extends MessagingAdapter $resource = $parts[4] ?? ''; if (in_array($resource, ['columns', 'attributes', 'indexes'])) { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } elseif (in_array($resource, ['rows', 'documents'])) { @@ -638,8 +638,8 @@ class Realtime extends MessagingAdapter throw new \Exception('Bucket needs to be passed to Realtime for File events in the Storage.'); } $channels[] = 'files'; - $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files'; - $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files.'.$payload->getId(); + $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files'; + $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.'.$payload->getId(); $roles = $bucket->getAttribute('fileSecurity', false) ? \array_merge($bucket->getRead(), $payload->getRead()) @@ -649,17 +649,17 @@ class Realtime extends MessagingAdapter break; case 'functions': if ($parts[2] === 'executions') { - if (! empty($payload->getRead())) { + if (!empty($payload->getRead())) { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $channels[] = 'executions'; - $channels[] = 'executions.'.$payload->getId(); - $channels[] = 'functions.'.$payload->getAttribute('functionId'); + $channels[] = 'executions.' . $payload->getId(); + $channels[] = 'functions.' . $payload->getAttribute('functionId'); $roles = $payload->getRead(); } } elseif ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } @@ -668,7 +668,7 @@ class Realtime extends MessagingAdapter case 'sites': if ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } @@ -702,7 +702,7 @@ class Realtime extends MessagingAdapter $action = null; } - if ($action !== null && ! empty($channels)) { + if ($action !== null && !empty($channels)) { $augmented = $channels; foreach ($channels as $channel) { $segments = \explode('.', $channel); @@ -711,7 +711,7 @@ class Realtime extends MessagingAdapter $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true); if ($leafIsResource || $parentIsResource) { - $augmented[] = $channel.'.'.$action; + $augmented[] = $channel. '.' .$action; } } $channels = \array_values(\array_unique($augmented)); @@ -726,15 +726,12 @@ class Realtime extends MessagingAdapter } /** - * 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 - * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) - * @return array Array of channel names + * @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 + * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) */ private static function getDatabaseChannels( string $type = 'databases', @@ -745,7 +742,7 @@ class Realtime extends MessagingAdapter ): array { $basePrefix = $prefixOverride ?: $type; - if (! $databaseId || ! $resourceId || ! $payloadId) { + if (!$databaseId || !$resourceId || !$payloadId) { return []; } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index d66a86a4f1..1740b3d62a 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -734,7 +734,7 @@ class MessagingTest extends TestCase $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); } - public function test_from_payload_does_not_suffix_account_for_nested_user_events(): void + public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents(): void { // Nested user events (challenges/sessions/recovery/verification) emit only // user-level account channels in fromPayload. The trailing action belongs to From ca105ff9bc381472ad27b4e8737f41666dbd872a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:31:31 +0530 Subject: [PATCH 13/56] feat(Realtime): implement rebindAccountChannels method for userId changes and add corresponding tests --- app/realtime.php | 20 ++++++- src/Appwrite/Messaging/Adapter/Realtime.php | 38 +++++++++++++ tests/unit/Messaging/MessagingTest.php | 63 +++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 0e7388b83f..bc95fc6cdc 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -566,6 +566,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $roles = $user->getRoles($database->getAuthorization()); $authorization = $realtime->connections[$connection]['authorization'] ?? null; + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $meta = $realtime->getSubscriptionMetadata($connection); @@ -573,12 +574,17 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $userId + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], + $channels, $queries ); } @@ -1068,6 +1074,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + // Capture the pre-auth userId so we can rebind any account channels + // that were stored under it (e.g. guest who subscribed to `account` + // and now authenticates). unsubscribe() below clears the connection + // entry, so we must read it first. + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); @@ -1077,13 +1088,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if (!empty($projectId)) { foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $user->getId() + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], + $channels, $queries, $user->getId() ); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7526df9dd0..33b2e76889 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -437,6 +437,44 @@ class Realtime extends MessagingAdapter return $channels; } + /** + * Rewrites stored account channels (`account.{oldUserId}` and + * `account.{oldUserId}.{action}`) to match a new userId. Used when in-band + * authentication changes the connection's user identity (typically + * guest → authenticated user, or rare reauth as a different user) — without + * this, channels stay bound to the old userId and the connection silently + * receives the previous user's account events. + * + * Returns channels unchanged when the user identity has not changed + * (oldUserId === newUserId) or when the connection had no userId previously + * (guest connections never store userId-suffixed channels because + * convertChannels strips the suffix when userId is empty). + */ + public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array + { + if ($oldUserId === '' || $oldUserId === $newUserId) { + return $channels; + } + + $oldExact = 'account.'.$oldUserId; + $oldPrefix = $oldExact.'.'; + + return \array_map(function (string $channel) use ($oldExact, $oldPrefix, $newUserId) { + if ($channel === $oldExact) { + return 'account.'.$newUserId; + } + + if (\str_starts_with($channel, $oldPrefix)) { + $action = \substr($channel, \strlen($oldPrefix)); + if (\in_array($action, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$action; + } + } + + return $channel; + }, $channels); + } + /** * Constructs subscriptions from query parameters. * diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 1740b3d62a..395a4fba01 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -446,6 +446,69 @@ class MessagingTest extends TestCase $this->assertArrayHasKey('account', $channels); } + public function test_rebind_account_channels_remaps_after_reauth(): void + { + // Captures the in-band auth scenario: a guest connects and subscribes to + // `account` (stored as `account` because there's no userId). After the + // client sends an authentication message, the connection's userId becomes + // 'B' — but its stored channels are still bound to whatever the previous + // identity was. This helper rewrites them so the resubscribe lands on the + // new user's account namespace. + $rebound = Realtime::rebindAccountChannels( + ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], + 'A', + 'B', + ); + + // account-scoped channels are rebound to the new user. + $this->assertContains('account.B', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.A', $rebound); + $this->assertNotContains('account.A.create', $rebound); + $this->assertNotContains('account.A.update', $rebound); + + // Non-account channels are left alone — the rewrite must be precise. + $this->assertContains('documents', $rebound); + $this->assertContains('documents.A.something', $rebound); + } + + public function test_rebind_account_channels_is_noop_for_unchanged_user(): void + { + // Same user → nothing to rewrite. Avoids unnecessary churn when the + // permissionsChanged path fires (roles change but userId is constant). + $channels = ['account.A', 'account.A.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); + } + + public function test_rebind_account_channels_is_noop_for_guest_origin(): void + { + // Guest connections never store userId-suffixed channels (convertChannels + // strips the suffix when userId is empty), so rebinding from '' to a real + // userId should be a no-op — the plain `account` channel doesn't carry + // any userId binding to remap. + $channels = ['account', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', 'B')); + } + + public function test_rebind_account_channels_only_remaps_known_actions(): void + { + // Defensive: we intentionally restrict the rewrite to suffixes in + // SUPPORTED_ACTIONS so we don't accidentally rewrite a channel that + // happens to have `account.{userId}.{something}` shape from outside the + // documented set. + $rebound = Realtime::rebindAccountChannels( + ['account.A.bogus', 'account.A.create'], + 'A', + 'B', + ); + + $this->assertContains('account.A.bogus', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertNotContains('account.B.bogus', $rebound); + $this->assertNotContains('account.A.create', $rebound); + } + public function test_from_payload_permissions(): void { /** From 9553f8a9f88901a7606bdcae96ced8eaf35c6346 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:35:56 +0530 Subject: [PATCH 14/56] refactor(MessagingTest): update method visibility and naming conventions for consistency --- tests/unit/Messaging/MessagingTest.php | 180 +++++-------------------- 1 file changed, 33 insertions(+), 147 deletions(-) diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 395a4fba01..9190bdbb83 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - protected function setUp(): void + public function setUp(): void { } - protected function tearDown(): void + public function tearDown(): void { } - public function test_user(): void + public function testUser(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ], - ], + ] + ] ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function test_subscribe_unions_channels_and_roles(): void + public function testSubscribeUnionsChannelsAndRoles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function test_unsubscribe_subscription_removes_only_one_subscription(): void + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function test_unsubscribe_subscription_is_idempotent(): void + public function testUnsubscribeSubscriptionIsIdempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function test_convert_channels_guest(): void + public function testConvertChannelsGuest(): void { $user = new Document([ - '$id' => '', + '$id' => '' ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_convert_channels_user(): void + public function testConvertChannelsUser(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator', - ], + 'moderator' + ] ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest', - ], - ], - ], + 'guest' + ] + ] + ] ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,120 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_convert_channels_rewrites_account_action_suffixes(): void - { - // A subscriber to `account.{action}` should receive the user-scoped - // `account.{userId}.{action}` channel that fromPayload publishes for - // top-level user events. Without the rewrite the channel would either be - // stripped (security guard against subscribing to other users' account) - // or, if left literal, leak every user's account events to this client. - $channels = Realtime::convertChannels( - ['account.create', 'account.update', 'account.upsert', 'account.delete'], - '123', - ); - - $this->assertArrayHasKey('account.123.create', $channels); - $this->assertArrayHasKey('account.123.update', $channels); - $this->assertArrayHasKey('account.123.upsert', $channels); - $this->assertArrayHasKey('account.123.delete', $channels); - - // The literal forms must not survive — they would otherwise match every - // user's events, not just the subscribed user's. - $this->assertArrayNotHasKey('account.create', $channels); - $this->assertArrayNotHasKey('account.update', $channels); - $this->assertArrayNotHasKey('account.upsert', $channels); - $this->assertArrayNotHasKey('account.delete', $channels); - - // Other-user channels and unknown action-like suffixes still get stripped. - $channels = Realtime::convertChannels( - ['account.other_id', 'account.bogus', 'account.123', 'account.create'], - '123', - ); - $this->assertArrayNotHasKey('account.other_id', $channels); - $this->assertArrayNotHasKey('account.bogus', $channels); - $this->assertArrayNotHasKey('account.123', $channels); - $this->assertArrayHasKey('account.123.create', $channels); - } - - public function test_convert_channels_drops_account_actions_for_guest(): void - { - // No userId → no place to scope the action-suffixed channel, so the - // action-suffixed forms are dropped entirely. Plain `account` survives - // (matching existing guest behavior — see test_convert_channels_guest). - $channels = Realtime::convertChannels( - ['account.create', 'account.update', 'account'], - '', - ); - - $this->assertArrayNotHasKey('account.create', $channels); - $this->assertArrayNotHasKey('account.update', $channels); - $this->assertArrayHasKey('account', $channels); - } - - public function test_rebind_account_channels_remaps_after_reauth(): void - { - // Captures the in-band auth scenario: a guest connects and subscribes to - // `account` (stored as `account` because there's no userId). After the - // client sends an authentication message, the connection's userId becomes - // 'B' — but its stored channels are still bound to whatever the previous - // identity was. This helper rewrites them so the resubscribe lands on the - // new user's account namespace. - $rebound = Realtime::rebindAccountChannels( - ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], - 'A', - 'B', - ); - - // account-scoped channels are rebound to the new user. - $this->assertContains('account.B', $rebound); - $this->assertContains('account.B.create', $rebound); - $this->assertContains('account.B.update', $rebound); - $this->assertNotContains('account.A', $rebound); - $this->assertNotContains('account.A.create', $rebound); - $this->assertNotContains('account.A.update', $rebound); - - // Non-account channels are left alone — the rewrite must be precise. - $this->assertContains('documents', $rebound); - $this->assertContains('documents.A.something', $rebound); - } - - public function test_rebind_account_channels_is_noop_for_unchanged_user(): void - { - // Same user → nothing to rewrite. Avoids unnecessary churn when the - // permissionsChanged path fires (roles change but userId is constant). - $channels = ['account.A', 'account.A.create', 'documents']; - $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); - } - - public function test_rebind_account_channels_is_noop_for_guest_origin(): void - { - // Guest connections never store userId-suffixed channels (convertChannels - // strips the suffix when userId is empty), so rebinding from '' to a real - // userId should be a no-op — the plain `account` channel doesn't carry - // any userId binding to remap. - $channels = ['account', 'documents']; - $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', 'B')); - } - - public function test_rebind_account_channels_only_remaps_known_actions(): void - { - // Defensive: we intentionally restrict the rewrite to suffixes in - // SUPPORTED_ACTIONS so we don't accidentally rewrite a channel that - // happens to have `account.{userId}.{something}` shape from outside the - // documented set. - $rebound = Realtime::rebindAccountChannels( - ['account.A.bogus', 'account.A.create'], - 'A', - 'B', - ); - - $this->assertContains('account.A.bogus', $rebound); - $this->assertContains('account.B.create', $rebound); - $this->assertNotContains('account.B.bogus', $rebound); - $this->assertNotContains('account.A.create', $rebound); - } - - public function test_from_payload_permissions(): void + public function testFromPayloadPermissions(): void { /** * Test Collection Level Permissions @@ -573,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function test_from_payload_bucket_level_permissions(): void + public function testFromPayloadBucketLevelPermissions(): void { /** * Test Bucket Level Permissions @@ -623,15 +510,14 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true, + 'fileSecurity' => true ]) ); $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - - public function test_from_payload_emits_action_suffixed_channels(): void + public function testFromPayloadEmitsActionSuffixedChannels(): void { $result = Realtime::fromPayload( event: 'databases.database_id.collections.collection_id.documents.document_id.create', @@ -663,7 +549,7 @@ class MessagingTest extends TestCase $this->assertNotContains('documents.delete', $result['channels']); } - public function test_from_payload_emits_action_suffix_for_every_action(): void + public function testFromPayloadEmitsActionSuffixForEveryAction(): void { foreach (['create', 'update', 'upsert', 'delete'] as $action) { $result = Realtime::fromPayload( @@ -690,7 +576,7 @@ class MessagingTest extends TestCase } } - public function test_from_payload_does_not_suffix_when_no_action(): void + public function testFromPayloadDoesNotSuffixWhenNoAction(): void { // Synthetic event without an action segment: e.g. an attribute event whose // last segment is not a known action and whose second-to-last segment is @@ -719,7 +605,7 @@ class MessagingTest extends TestCase $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); } - public function test_from_payload_does_not_suffix_admin_channels(): void + public function testFromPayloadDoesNotSuffixAdminChannels(): void { // Function execution event emits resource-leaf channels (executions / functions) // alongside admin channels (console / projects.X). Admin channels must NOT @@ -753,7 +639,7 @@ class MessagingTest extends TestCase $this->assertNotContains('projects.project_id.create', $result['channels']); } - public function test_from_payload_handles_attribute_trailing_action_events(): void + public function testFromPayloadHandlesAttributeTrailingActionEvents(): void { // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the // second-to-last segment, not the last one. The suffix must still be `.update`. @@ -836,7 +722,7 @@ class MessagingTest extends TestCase $this->assertContains('account.user_id.update', $updateResult['channels']); } - public function test_action_suffix_delivers_only_matching_action_end_to_end(): void + public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void { $realtime = new Realtime(); @@ -871,7 +757,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function test_plain_channel_still_receives_all_actions_end_to_end(): void + public function testPlainChannelStillReceivesAllActionsEndToEnd(): void { $realtime = new Realtime(); From 3f120622591cb737f4f327e2a43100d847ee5d2d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:54:48 +0530 Subject: [PATCH 15/56] updated --- app/realtime.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index bc95fc6cdc..48e2218f57 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -585,7 +585,8 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $subscriptionId, $roles, $channels, - $queries + $queries, + $userId ); } From cb8640b56f8e2ae600a8d053feb52ab3638f7163 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 18:24:52 +0530 Subject: [PATCH 16/56] feat(Realtime): enhance channel management for user authentication and account actions --- src/Appwrite/Messaging/Adapter/Realtime.php | 55 +++-- tests/unit/Messaging/MessagingTest.php | 242 ++++++++++++++++++++ 2 files changed, 278 insertions(+), 19 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 33b2e76889..bd4c3f80b4 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -418,11 +418,14 @@ class Realtime extends MessagingAdapter break; case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'): - // Translate `account.{action}` into the user-scoped `account.{userId}.{action}` - // so a subscriber only receives their own account events. Without the rewrite - // the literal `account.{action}` channel would match every user's events. - unset($channels[$key]); + // Authenticated: rewrite `account.{action}` → `account.{userId}.{action}` + // so the subscriber only receives their own account events. + // Guest: keep the literal `account.{action}` so the action filter + // applies to the broadcast `account.{action}` channel that fromPayload + // emits for top-level user events. On in-band auth, rebindAccountChannels + // rewrites the literal to the user-scoped form. if (!empty($userId)) { + unset($channels[$key]); $action = \substr($key, \strlen('account.')); $channels['account.'.$userId.'.'.$action] = $value; } @@ -438,32 +441,46 @@ class Realtime extends MessagingAdapter } /** - * Rewrites stored account channels (`account.{oldUserId}` and - * `account.{oldUserId}.{action}`) to match a new userId. Used when in-band - * authentication changes the connection's user identity (typically - * guest → authenticated user, or rare reauth as a different user) — without - * this, channels stay bound to the old userId and the connection silently - * receives the previous user's account events. + * Rewrites stored account channels to match a new userId. Used when in-band + * authentication changes the connection's user identity: * - * Returns channels unchanged when the user identity has not changed - * (oldUserId === newUserId) or when the connection had no userId previously - * (guest connections never store userId-suffixed channels because - * convertChannels strips the suffix when userId is empty). + * - guest → authenticated: rewrites the literal `account.{action}` form + * that convertChannels preserves for guests into `account.{userId}.{action}`. + * - reauth as a different user: rewrites `account.{oldUserId}` and + * `account.{oldUserId}.{action}` to the new userId. + * + * Returns channels unchanged when there's nothing to do — same user, or an + * empty target (defensive: avoids producing malformed `account.` strings if + * a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow). */ public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array { - if ($oldUserId === '' || $oldUserId === $newUserId) { + if ($newUserId === '' || $oldUserId === $newUserId) { return $channels; } - $oldExact = 'account.'.$oldUserId; - $oldPrefix = $oldExact.'.'; + return \array_map(function (string $channel) use ($oldUserId, $newUserId) { + if (!\str_starts_with($channel, 'account.')) { + return $channel; + } - return \array_map(function (string $channel) use ($oldExact, $oldPrefix, $newUserId) { - if ($channel === $oldExact) { + // Guest origin: literal `account.{action}` (preserved by convertChannels + // for unauthenticated connections) becomes `account.{newUserId}.{action}`. + if ($oldUserId === '') { + $suffix = \substr($channel, \strlen('account.')); + if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$suffix; + } + + return $channel; + } + + // Authenticated → different user. + if ($channel === 'account.'.$oldUserId) { return 'account.'.$newUserId; } + $oldPrefix = 'account.'.$oldUserId.'.'; if (\str_starts_with($channel, $oldPrefix)) { $action = \substr($channel, \strlen($oldPrefix)); if (\in_array($action, self::SUPPORTED_ACTIONS, true)) { diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 9190bdbb83..6fbb5a3e68 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -396,6 +396,248 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } + public function testConvertChannelsRewritesAccountActionSuffixes(): void + { + // Authenticated subscriber to `account.{action}` is translated to the + // user-scoped `account.{userId}.{action}` form so events from other + // users' accounts don't leak through the literal channel. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete'], + '123', + ); + + $this->assertArrayHasKey('account.123.create', $channels); + $this->assertArrayHasKey('account.123.update', $channels); + $this->assertArrayHasKey('account.123.upsert', $channels); + $this->assertArrayHasKey('account.123.delete', $channels); + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayNotHasKey('account.upsert', $channels); + $this->assertArrayNotHasKey('account.delete', $channels); + + // Other-user channels and unknown action-like suffixes still get stripped. + $channels = Realtime::convertChannels( + ['account.other_id', 'account.bogus', 'account.123', 'account.create'], + '123', + ); + $this->assertArrayNotHasKey('account.other_id', $channels); + $this->assertArrayNotHasKey('account.bogus', $channels); + $this->assertArrayNotHasKey('account.123', $channels); + $this->assertArrayHasKey('account.123.create', $channels); + } + + public function testConvertChannelsPreservesAccountActionsForGuest(): void + { + // Guests can't scope an action filter to a userId yet, so `account.{action}` + // is preserved verbatim. fromPayload publishes the unscoped `account.{action}` + // channel for top-level user events, so the guest's stored form matches and + // delivers correctly. After the connection authenticates, + // rebindAccountChannels rewrites the literal to `account.{userId}.{action}` + // so the action filter survives the auth transition. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete', 'account'], + '', + ); + + $this->assertArrayHasKey('account.create', $channels); + $this->assertArrayHasKey('account.update', $channels); + $this->assertArrayHasKey('account.upsert', $channels); + $this->assertArrayHasKey('account.delete', $channels); + $this->assertArrayHasKey('account', $channels); + } + + public function testRebindAccountChannelsRemapsAfterReauth(): void + { + // Reauth as a different user must remap the user-scoped channels so the + // connection no longer receives the previous user's account events. + $rebound = Realtime::rebindAccountChannels( + ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], + 'A', + 'B', + ); + + $this->assertContains('account.B', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.A', $rebound); + $this->assertNotContains('account.A.create', $rebound); + $this->assertNotContains('account.A.update', $rebound); + + // Non-account channels left alone — the rewrite is precise. + $this->assertContains('documents', $rebound); + $this->assertContains('documents.A.something', $rebound); + } + + public function testRebindAccountChannelsIsNoopForUnchangedUser(): void + { + // Same user → nothing to rewrite. Avoids unnecessary churn when the + // permissionsChanged path fires (roles change, userId is constant). + $channels = ['account.A', 'account.A.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); + } + + public function testRebindAccountChannelsIsNoopForEmptyTarget(): void + { + // Defensive: if a caller ever passes an empty $newUserId (e.g. a + // hypothetical in-band logout), we leave channels untouched rather than + // producing malformed `account.` strings. + $channels = ['account.A', 'account.A.create', 'account.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', '')); + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', '')); + } + + public function testRebindAccountChannelsPromotesGuestActionFilters(): void + { + // Guest connections store `account.{action}` literally (convertChannels + // preserves the form when userId is empty). On in-band authentication, + // rebindAccountChannels promotes those literals to user-scoped form so + // the action filter survives. + $rebound = Realtime::rebindAccountChannels( + ['account', 'account.create', 'account.update', 'documents'], + '', + 'B', + ); + + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.create', $rebound); + $this->assertNotContains('account.update', $rebound); + + // Plain `account` and unrelated channels are left alone. + $this->assertContains('account', $rebound); + $this->assertContains('documents', $rebound); + } + + public function testRebindAccountChannelsOnlyRemapsKnownActions(): void + { + // Defensive: only suffixes in SUPPORTED_ACTIONS are rewritten, so a + // channel like `account.A.bogus` stays intact rather than being + // silently rebound. + $rebound = Realtime::rebindAccountChannels( + ['account.A.bogus', 'account.A.create'], + 'A', + 'B', + ); + + $this->assertContains('account.A.bogus', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertNotContains('account.B.bogus', $rebound); + $this->assertNotContains('account.A.create', $rebound); + } + + public function testReauthThenPermissionsChangeThenReauthPreservesAccountAction(): void + { + // Full lifecycle, mirrors the auth + permissionsChanged handler logic in + // app/realtime.php: + // 1. user A subscribes to account.create (stored as account.A.create) + // 2. in-band reauth as B → rebound to account.B.create, userId=B + // 3. permissions-change for B → userId on connection MUST stay 'B' + // so a subsequent reauth as C still has previousUserId='B'. + // 4. reauth as C → rebound to account.C.create, userId=C + $realtime = new Realtime(); + + // Step 1. + $aChannels = \array_keys(Realtime::convertChannels(['account.create'], 'A')); + $this->assertSame(['account.A.create'], $aChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::user(ID::custom('A'))->toString()], $aChannels, [], 'A'); + $this->assertSame('A', $realtime->connections[1]['userId']); + + // Step 2: A → B. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 3: permissions-change for B (userId stays 'B'). + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 4: B → C. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'C'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('C'))->toString()], $rebound, [], 'C'); + } + $this->assertSame('C', $realtime->connections[1]['userId']); + $this->assertContains('account.C.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.B.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.A.create', $realtime->connections[1]['channels']); + } + + public function testGuestAccountActionFilterSurvivesAuthenticationEndToEnd(): void + { + // Full lifecycle: + // 1. Guest connects, subscribes to `account.create`. + // 2. fromPayload publishes a top-level `users.B.create` event — guest + // receives it via the unscoped `account.create` broadcast channel. + // 3. Guest authenticates as B. Resubscribe goes through + // rebindAccountChannels so the same subscription is now scoped to + // `account.B.create` and only matches B's events. + $realtime = new Realtime(); + + // Step 1: guest subscribes. convertChannels preserves the literal form. + $guestChannels = \array_keys(Realtime::convertChannels(['account.create'], '')); + $this->assertSame(['account.create'], $guestChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::guests()->toString()], $guestChannels, [], ''); + + // Step 2: fromPayload publishes account.create alongside the user-scoped form. + $publish = Realtime::fromPayload( + event: 'users.B.create', + payload: new Document(['$id' => ID::custom('B')]), + ); + $this->assertContains('account.create', $publish['channels']); + $this->assertContains('account.B.create', $publish['channels']); + + // Guest receives the unscoped channel. + $event = [ + 'project' => '1', + 'roles' => [Role::guests()->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + // Step 3: in-band auth promotes the guest to user 'B'. + $previousUserId = $realtime->connections[1]['userId'] ?? ''; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + + // Literal channel is gone; user-scoped form is in place. + $this->assertNotContains('account.create', $realtime->connections[1]['channels']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // B-scoped event delivers via the user-scoped channel. + $bEvent = [ + 'project' => '1', + 'roles' => [Role::user(ID::custom('B'))->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($bEvent)); + } + public function testFromPayloadPermissions(): void { /** From 1fdcca959293ac5db482fc153dd8294179c7c720 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 18:33:47 +0530 Subject: [PATCH 17/56] added a guard to skip double import --- app/realtime.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 0e7388b83f..88b1137c30 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -45,7 +45,10 @@ use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; -require_once __DIR__ . '/init/span.php'; + +if (!defined('APPWRITE_SKIP_CE_SPAN_INIT')) { + require_once __DIR__ . '/init/span.php'; +} /** @var Registry $register */ $register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); From 70b9c60e2cf8f6e653d3f777fb2d917339685a7b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 18:46:04 +0530 Subject: [PATCH 18/56] test(Messaging): validate that bare functions channel is not emitted in published channels --- src/Appwrite/Messaging/Adapter/Realtime.php | 67 ++++++++++++++++++--- tests/unit/Messaging/MessagingTest.php | 8 +++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index bd4c3f80b4..5a9c02a2bd 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -16,6 +16,15 @@ class Realtime extends MessagingAdapter { public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + // Resources whose channels receive an action-suffixed sibling at publish time. + // The suffix loop in fromPayload() treats any channel whose last OR second-to-last + // segment matches an entry here as a candidate for `.{action}` suffixing. + // + // `functions` is intentionally a parent-only entry: fromPayload publishes + // `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but + // never emits a bare `functions` channel — so subscribing to bare + // `functions.{action}` is a silent no-op. Per-function filters + // (`functions.{functionId}.{action}`) are the supported form. private const RESOURCE_LEAF_NAMES = [ 'documents', 'rows', @@ -72,11 +81,13 @@ class Realtime extends MessagingAdapter /** * Adds a subscription with a specific subscription ID. * - * @param mixed $identifier Connection ID - * @param string $subscriptionId Unique subscription ID - * @param array $roles User roles - * @param array $channels Channels to subscribe to (array of channel names) - * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) + * @param string $projectId + * @param mixed $identifier Connection ID + * @param string $subscriptionId Unique subscription ID + * @param array $roles User roles + * @param array $channels Channels to subscribe to (array of channel names) + * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) + * @return void */ public function subscribe( string $projectId, @@ -148,7 +159,7 @@ class Realtime extends MessagingAdapter * Get subscription metadata for a connection. * Retrieves subscription data including channels and queries directly from the subscriptions tree. * - * @param mixed $connection Connection ID + * @param mixed $connection Connection ID * @return array Array of [subscriptionId => ['channels' => string[], 'queries' => string[]]] */ public function getSubscriptionMetadata(mixed $connection): array @@ -193,6 +204,9 @@ class Realtime extends MessagingAdapter /** * Removes all subscriptions for a connection. + * + * @param mixed $connection + * @return void */ public function unsubscribe(mixed $connection): void { @@ -226,6 +240,10 @@ class Realtime extends MessagingAdapter /** * Removes a single subscription from a connection, keeping the connection alive so * the client can resubscribe. Idempotent — returns true only when something was removed. + * + * @param mixed $connection + * @param string $subscriptionId + * @return bool */ public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool { @@ -276,6 +294,9 @@ class Realtime extends MessagingAdapter * context (set at onOpen, replaced on `authentication` / permission-change) and must survive * per-subscription removal — otherwise a client that unsubscribes every subscription and then * resubscribes would subscribe with an empty roles array and silently receive nothing. + * + * @param mixed $connection + * @return void */ private function recomputeConnectionState(mixed $connection): void { @@ -299,6 +320,10 @@ class Realtime extends MessagingAdapter /** * Checks if Channel has a subscriber. + * @param string $projectId + * @param string $role + * @param string $channel + * @return bool */ public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool { @@ -316,6 +341,13 @@ class Realtime extends MessagingAdapter /** * Sends an event to the Realtime Server + * @param string $projectId + * @param array $payload + * @param array $events + * @param array $channels + * @param array $roles + * @param array $options + * @return void * * @throws \Exception */ @@ -404,6 +436,11 @@ class Realtime extends MessagingAdapter * `account.delete`) to `account.USER_ID.{action}` so they match the channels * fromPayload() publishes for top-level user events, and removes all other * illegal account channel variations (e.g. another user's `account.{otherId}`). + * + * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. + * @param array $channels + * @param string $userId + * @return array */ public static function convertChannels(array $channels, string $userId): array { @@ -495,6 +532,8 @@ class Realtime extends MessagingAdapter /** * Constructs subscriptions from query parameters. * + * @param array $channelNames + * @param callable $getQueryParam * @return array [index => ['channels' => string[], 'queries' => Query[]]] * * @throws QueryException @@ -566,8 +605,8 @@ class Realtime extends MessagingAdapter /** * Converts the queries from the Query Params into an array. - * - * @param array|string $queries + * @param array|string $queries + * @return array * * @throws QueryException */ @@ -602,6 +641,13 @@ class Realtime extends MessagingAdapter /** * Create channels array based on the event name and payload. * + * @param string $event + * @param Document $payload + * @param Document|null $project + * @param Document|null $database + * @param Document|null $collection + * @param Document|null $bucket + * @return array * @throws \Exception */ public static function fromPayload(string $event, Document $payload, ?Document $project = null, ?Document $database = null, ?Document $collection = null, ?Document $bucket = null): array @@ -694,7 +740,7 @@ class Realtime extends MessagingAdapter } $channels[] = 'files'; $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files'; - $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.'.$payload->getId(); + $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.' . $payload->getId(); $roles = $bucket->getAttribute('fileSecurity', false) ? \array_merge($bucket->getRead(), $payload->getRead()) @@ -781,12 +827,15 @@ class Realtime extends MessagingAdapter } /** + * 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 * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) + * @return array Array of channel names */ private static function getDatabaseChannels( string $type = 'databases', diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 6fbb5a3e68..bf901bbe43 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -879,6 +879,14 @@ class MessagingTest extends TestCase $this->assertNotContains('console.create', $result['channels']); $this->assertContains('projects.project_id', $result['channels']); $this->assertNotContains('projects.project_id.create', $result['channels']); + + // The bare `functions` channel is never emitted by fromPayload (only + // `functions.{functionId}` is). The per-function action variant + // (`functions.{functionId}.create`) is the supported subscription + // form — bare `functions.create` would be a silent no-op and must + // therefore NOT appear in the published channel set either. + $this->assertNotContains('functions', $result['channels']); + $this->assertNotContains('functions.create', $result['channels']); } public function testFromPayloadHandlesAttributeTrailingActionEvents(): void From f71a2dfddc63e60166889d7c31c98f58a80605d0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 28 Apr 2026 11:07:16 +0530 Subject: [PATCH 19/56] changed the condition to app edition for the loading of the span --- app/realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 88b1137c30..caa105eace 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -46,7 +46,7 @@ use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; -if (!defined('APPWRITE_SKIP_CE_SPAN_INIT')) { +if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') { require_once __DIR__ . '/init/span.php'; } From d73b7a70d8d12f6772083b02abcec7c98cb9514f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 11:44:39 +0530 Subject: [PATCH 20/56] feat: add query param fallback for impersonation headers Allow impersonation to be specified via URL query params (?impersonateUserId, ?impersonateEmail, ?impersonatePhone) as a fallback to the existing headers, enabling Console to embed impersonation in direct file/image URLs where headers cannot be set. --- app/init/realtime/connection.php | 6 +++--- app/init/resources/request.php | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c0219fa816..0822ee9329 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,9 +327,9 @@ return function (Container $container): void { } } - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 7d1731b80d..26c03126a2 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -571,10 +571,10 @@ return function (Container $container): void { } } - // Impersonation: if current user has impersonator capability and headers are set, act as another user - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Impersonation: if current user has impersonator capability and headers/params are set, act as another user + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; From 01b5fa8ecb0b7f12044bce25388f86c7b585d4d9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 11:58:25 +0530 Subject: [PATCH 21/56] fix: restrict impersonation query param fallback to userId only Remove query param fallback for impersonateEmail and impersonatePhone to avoid PII exposure in server logs, browser history, and Referer headers. Only impersonateUserId (an opaque internal ID) is safe to pass via URL query param. --- app/init/realtime/connection.php | 4 ++-- app/init/resources/request.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 0822ee9329..1f6faed0fd 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -328,8 +328,8 @@ return function (Container $container): void { } $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 26c03126a2..8a74f7763b 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -573,8 +573,8 @@ return function (Container $container): void { // Impersonation: if current user has impersonator capability and headers/params are set, act as another user $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; From 8f1d73a6cb7d2368589d0c9f073fc99fdb03f665 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:02:00 +0530 Subject: [PATCH 22/56] chore: clarify intentional header-only restriction for email/phone impersonation --- app/init/realtime/connection.php | 2 ++ app/init/resources/request.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 1f6faed0fd..b557a2c62b 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,6 +327,8 @@ return function (Container $container): void { } } + // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. + // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 8a74f7763b..c6f3fd1ab1 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -572,6 +572,8 @@ return function (Container $container): void { } // Impersonation: if current user has impersonator capability and headers/params are set, act as another user + // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) + // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); From 4c989f99c37043c0b9dafd877e3d74f239c2d160 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:05:02 +0530 Subject: [PATCH 23/56] fix: cast impersonateUserId query param to string to prevent array injection --- app/init/realtime/connection.php | 2 +- app/init/resources/request.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index b557a2c62b..c02da3058e 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -329,7 +329,7 @@ return function (Container $container): void { // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index c6f3fd1ab1..d1c0d2bea0 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -574,7 +574,7 @@ return function (Container $container): void { // Impersonation: if current user has impersonator capability and headers/params are set, act as another user // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { From 46a457bfa37960ecf28f59baeb244077e19cbe21 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:10:51 +0530 Subject: [PATCH 24/56] fix: block impersonateUserId query param on cross-site requests to prevent CSRF --- app/init/realtime/connection.php | 5 ++++- app/init/resources/request.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c02da3058e..3bb91a3aeb 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -329,7 +329,10 @@ return function (Container $container): void { // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via + // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. + $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index d1c0d2bea0..143adca352 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -574,7 +574,10 @@ return function (Container $container): void { // Impersonation: if current user has impersonator capability and headers/params are set, act as another user // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; + // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. + $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { From 5465be6301a3a5b0236bc0c8b9c0d93b822260a5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:27:57 +0530 Subject: [PATCH 25/56] fix: make CSRF guard fail-closed by requiring explicit same-origin Sec-Fetch-Site --- app/init/realtime/connection.php | 5 +++-- app/init/resources/request.php | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 3bb91a3aeb..0fc30fb5e2 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -331,8 +331,9 @@ return function (Container $container): void { // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. - $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); + $fetchSite = $request->getHeader('sec-fetch-site', ''); + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 143adca352..7b29c05c5d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -576,8 +576,9 @@ return function (Container $container): void { // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. - $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); + $fetchSite = $request->getHeader('sec-fetch-site', ''); + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { From 9a175c509897e8264974bb924bef6f4286ffd6e1 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:56:17 +0530 Subject: [PATCH 26/56] test: add E2E tests for impersonateUserId query param and CSRF guards --- tests/e2e/Services/Users/UsersBase.php | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 3255d9a67f..a4567f0063 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2708,6 +2708,158 @@ trait UsersBase $this->assertIsArray($response['body']['users']); } + /** + * Test impersonation via ?impersonateUserId= query param (same-origin browser request). + * This is the primary use case for embedding impersonation in file/image URLs where + * custom headers cannot be set (e.g. , deployment source/output download links). + */ + public function testImpersonateByUserIdQueryParam(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'queryparam-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'Query Param Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'queryparam-target@appwrite.io', + 'password' => 'password', + 'name' => 'Query Param Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + // Query param works when Sec-Fetch-Site indicates a same-origin browser request + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + 'sec-fetch-site' => 'same-origin', + ], ['impersonateUserId' => $idB]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals('Query Param Target', $account['body']['name']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + } + + /** + * Test that ?impersonateUserId= query param is ignored for cross-site requests (CSRF guard). + * Sec-Fetch-Site is a browser-enforced forbidden header; cross-site value means the request + * originated from a third-party page and must not be allowed to trigger impersonation. + */ + public function testImpersonateQueryParamIgnoredCrossSite(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-target@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + // Query param must be ignored when Sec-Fetch-Site is cross-site (third-party page embed) + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + 'sec-fetch-site' => 'cross-site', + ], ['impersonateUserId' => $idB]); + $this->assertEquals(200, $account['headers']['status-code']); + // Should resolve as userA (the impersonator), not the target + $this->assertEquals($idA, $account['body']['$id']); + $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + } + + /** + * Test that ?impersonateUserId= query param is ignored when Sec-Fetch-Site is absent + * (fail-closed CSRF guard). Absent header means a reverse proxy stripped Fetch Metadata + * headers or a non-browser client is calling — query param must be silently ignored. + */ + public function testImpersonateQueryParamIgnoredWhenSecFetchSiteAbsent(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'absent-fetch-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'Absent Fetch Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'absent-fetch-target@appwrite.io', + 'password' => 'password', + 'name' => 'Absent Fetch Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + // Query param must be ignored when Sec-Fetch-Site is absent (proxy-stripped or API client) + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + // no sec-fetch-site header + ], ['impersonateUserId' => $idB]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idA, $account['body']['$id']); + $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + } + /** * Test PATCH /users/:userId/impersonator for non-existent user returns 404 */ From a3f6cf4645cf5680fc237b3e9a17472b4c986e3c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:00:18 +0530 Subject: [PATCH 27/56] fix: restrict CSRF guard to same-origin only, drop same-site --- app/init/realtime/connection.php | 2 +- app/init/resources/request.php | 2 +- tests/e2e/Services/Users/UsersBase.php | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 0fc30fb5e2..5778b5c260 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -332,7 +332,7 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $isSameOrigin = $fetchSite === 'same-origin'; $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 7b29c05c5d..dca4b84bd7 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -577,7 +577,7 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $isSameOrigin = $fetchSite === 'same-origin'; $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index a4567f0063..5f38df5c07 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2746,7 +2746,8 @@ trait UsersBase $this->assertEquals(201, $session['headers']['status-code']); $sessionSecret = $session['body']['secret']; - // Query param works when Sec-Fetch-Site indicates a same-origin browser request + // Query param works only when Sec-Fetch-Site is exactly same-origin. + // same-site is intentionally excluded to prevent subdomain-based CSRF attacks. $account = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From ed0c7b4e129ba10171006e745862a19546c45837 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:24:15 +0530 Subject: [PATCH 28/56] test: add CSRF attack prevention test for impersonateUserId query param --- tests/e2e/Services/Users/UsersBase.php | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 5f38df5c07..069a2eab48 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2708,6 +2708,106 @@ trait UsersBase $this->assertIsArray($response['body']['users']); } + /** + * Proves that the Sec-Fetch-Site CSRF guard prevents forced impersonation via query params. + * + * Attack scenario (without the guard): + * A malicious page on attacker.com embeds: + * + * The browser automatically attaches the impersonator's session cookies. + * Without any guard, the server would impersonate victim_id silently. + * + * Why Sec-Fetch-Site works: + * Browsers set Sec-Fetch-Site: cross-site on all cross-origin requests (img, fetch, etc.). + * It is a browser-enforced forbidden header — JavaScript cannot set or spoof it. + * We only accept ?impersonateUserId when Sec-Fetch-Site is exactly same-origin. + * + * This test proves three attack vectors are all blocked: + * 1. cross-site — attacker.com embeds pointing at Appwrite + * 2. same-site — attacker controls a subdomain (e.g. evil.appwrite.io) + * 3. absent — reverse proxy strips Fetch Metadata headers (fail-closed) + */ + public function testImpersonateQueryParamCsrfAttackPrevented(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + // Impersonator user (the victim whose session gets hijacked in the attack) + $impersonator = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-guard-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Guard Impersonator', + ]); + $this->assertEquals(201, $impersonator['headers']['status-code']); + $impersonatorId = $impersonator['body']['$id']; + + // Target user (who the attacker wants to impersonate) + $target = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-guard-target@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Guard Target', + ]); + $this->assertEquals(201, $target['headers']['status-code']); + $targetId = $target['body']['$id']; + + $this->client->call(Client::METHOD_PATCH, '/users/' . $impersonatorId . '/impersonator', $headers, ['impersonator' => true]); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $impersonatorId . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + $sessionHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + ]; + + // Attack vector 1: cross-site (attacker.com embeds ) + // Browser sends Sec-Fetch-Site: cross-site — must be blocked. + $crossSite = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'cross-site']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $crossSite['headers']['status-code']); + $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); + $this->assertArrayNotHasKey('impersonatorUserId', $crossSite['body']); + + // Attack vector 2: same-site (attacker controls evil.appwrite.io subdomain) + // Browser sends Sec-Fetch-Site: same-site — must also be blocked. + $sameSite = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $sameSite['headers']['status-code']); + $this->assertEquals($impersonatorId, $sameSite['body']['$id'], 'same-site: subdomain attack must be blocked'); + $this->assertArrayNotHasKey('impersonatorUserId', $sameSite['body']); + + // Attack vector 3: absent header (reverse proxy strips Fetch Metadata headers) + // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. + $noFetchSite = $this->client->call(Client::METHOD_GET, '/account', + $sessionHeaders, + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $noFetchSite['headers']['status-code']); + $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); + $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); + + // Legitimate use: same-origin (Console loading a file URL with impersonation embedded) + // Browser sends Sec-Fetch-Site: same-origin — must succeed. + $sameOrigin = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $sameOrigin['headers']['status-code']); + $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); + $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); + } + /** * Test impersonation via ?impersonateUserId= query param (same-origin browser request). * This is the primary use case for embedding impersonation in file/image URLs where From 5afc8f462ddbd6e4466b5467693b57300c75e95e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:26:13 +0530 Subject: [PATCH 29/56] fix: allow same-site in CSRF guard to support Console on subdomains --- app/init/realtime/connection.php | 5 +++- app/init/resources/request.php | 5 +++- tests/e2e/Services/Users/UsersBase.php | 37 +++++++++++++------------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 5778b5c260..c6593927d9 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -332,7 +332,10 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = $fetchSite === 'same-origin'; + // Allow same-origin and same-site: Console may be served from a different subdomain + // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // cross-site and absent are blocked to prevent CSRF via third-party embeds. + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index dca4b84bd7..760b9d598e 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -577,7 +577,10 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = $fetchSite === 'same-origin'; + // Allow same-origin and same-site: Console may be served from a different subdomain + // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // cross-site and absent are blocked to prevent CSRF via third-party embeds. + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 069a2eab48..623e8cc3ec 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2722,10 +2722,11 @@ trait UsersBase * It is a browser-enforced forbidden header — JavaScript cannot set or spoof it. * We only accept ?impersonateUserId when Sec-Fetch-Site is exactly same-origin. * - * This test proves three attack vectors are all blocked: - * 1. cross-site — attacker.com embeds pointing at Appwrite - * 2. same-site — attacker controls a subdomain (e.g. evil.appwrite.io) - * 3. absent — reverse proxy strips Fetch Metadata headers (fail-closed) + * This test proves two attack vectors are blocked and two legitimate origins are allowed: + * Blocked: cross-site — attacker.com embeds pointing at Appwrite + * Blocked: absent — reverse proxy strips Fetch Metadata headers (fail-closed) + * Allowed: same-origin — Console on the same origin as the API + * Allowed: same-site — Console on a subdomain (e.g. vibes.appwrite.io vs appwrite.io) */ public function testImpersonateQueryParamCsrfAttackPrevented(): void { @@ -2777,17 +2778,7 @@ trait UsersBase $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); $this->assertArrayNotHasKey('impersonatorUserId', $crossSite['body']); - // Attack vector 2: same-site (attacker controls evil.appwrite.io subdomain) - // Browser sends Sec-Fetch-Site: same-site — must also be blocked. - $sameSite = $this->client->call(Client::METHOD_GET, '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $sameSite['headers']['status-code']); - $this->assertEquals($impersonatorId, $sameSite['body']['$id'], 'same-site: subdomain attack must be blocked'); - $this->assertArrayNotHasKey('impersonatorUserId', $sameSite['body']); - - // Attack vector 3: absent header (reverse proxy strips Fetch Metadata headers) + // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. $noFetchSite = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, @@ -2797,8 +2788,7 @@ trait UsersBase $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); - // Legitimate use: same-origin (Console loading a file URL with impersonation embedded) - // Browser sends Sec-Fetch-Site: same-origin — must succeed. + // Legitimate use 1: same-origin (Console on same origin as API) $sameOrigin = $this->client->call(Client::METHOD_GET, '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), ['impersonateUserId' => $targetId] @@ -2806,6 +2796,15 @@ trait UsersBase $this->assertEquals(200, $sameOrigin['headers']['status-code']); $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); + + // Legitimate use 2: same-site (Console on subdomain, e.g. vibes.appwrite.io vs appwrite.io) + $sameSite = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $sameSite['headers']['status-code']); + $this->assertEquals($targetId, $sameSite['body']['$id'], 'same-site: impersonation must succeed'); + $this->assertEquals($impersonatorId, $sameSite['body']['impersonatorUserId']); } /** @@ -2846,8 +2845,8 @@ trait UsersBase $this->assertEquals(201, $session['headers']['status-code']); $sessionSecret = $session['body']['secret']; - // Query param works only when Sec-Fetch-Site is exactly same-origin. - // same-site is intentionally excluded to prevent subdomain-based CSRF attacks. + // Query param works when Sec-Fetch-Site is same-origin or same-site. + // same-site covers Console deployed on a subdomain (e.g. vibes.appwrite.io). $account = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From 3dd5a51ba497d9f0e7a428001b2148f697bdc9b4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:34:01 +0530 Subject: [PATCH 30/56] style: fix method argument spacing (Pint PSR-12) --- tests/e2e/Services/Users/UsersBase.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 623e8cc3ec..862e858422 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2770,7 +2770,9 @@ trait UsersBase // Attack vector 1: cross-site (attacker.com embeds ) // Browser sends Sec-Fetch-Site: cross-site — must be blocked. - $crossSite = $this->client->call(Client::METHOD_GET, '/account', + $crossSite = $this->client->call( + Client::METHOD_GET, + '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'cross-site']), ['impersonateUserId' => $targetId] ); @@ -2780,7 +2782,9 @@ trait UsersBase // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. - $noFetchSite = $this->client->call(Client::METHOD_GET, '/account', + $noFetchSite = $this->client->call( + Client::METHOD_GET, + '/account', $sessionHeaders, ['impersonateUserId' => $targetId] ); @@ -2789,7 +2793,9 @@ trait UsersBase $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); // Legitimate use 1: same-origin (Console on same origin as API) - $sameOrigin = $this->client->call(Client::METHOD_GET, '/account', + $sameOrigin = $this->client->call( + Client::METHOD_GET, + '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), ['impersonateUserId' => $targetId] ); @@ -2798,7 +2804,9 @@ trait UsersBase $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); // Legitimate use 2: same-site (Console on subdomain, e.g. vibes.appwrite.io vs appwrite.io) - $sameSite = $this->client->call(Client::METHOD_GET, '/account', + $sameSite = $this->client->call( + Client::METHOD_GET, + '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), ['impersonateUserId' => $targetId] ); From bda823ac0e5923e57c478bb844ab3eac85b7a593 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:38:00 +0530 Subject: [PATCH 31/56] chore: format --- app/init/realtime/connection.php | 2 +- app/init/resources/request.php | 2 +- tests/e2e/Services/Users/UsersBase.php | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c6593927d9..03dfdc4fd7 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -333,7 +333,7 @@ return function (Container $container): void { // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. $fetchSite = $request->getHeader('sec-fetch-site', ''); // Allow same-origin and same-site: Console may be served from a different subdomain - // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // than the API, in which case the browser sends same-site. // cross-site and absent are blocked to prevent CSRF via third-party embeds. $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 760b9d598e..c0097a2416 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -578,7 +578,7 @@ return function (Container $container): void { // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. $fetchSite = $request->getHeader('sec-fetch-site', ''); // Allow same-origin and same-site: Console may be served from a different subdomain - // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // than the API, in which case the browser sends same-site. // cross-site and absent are blocked to prevent CSRF via third-party embeds. $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 862e858422..d5c06e9f8d 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2726,7 +2726,7 @@ trait UsersBase * Blocked: cross-site — attacker.com embeds pointing at Appwrite * Blocked: absent — reverse proxy strips Fetch Metadata headers (fail-closed) * Allowed: same-origin — Console on the same origin as the API - * Allowed: same-site — Console on a subdomain (e.g. vibes.appwrite.io vs appwrite.io) + * Allowed: same-site — Console on a different subdomain than the API */ public function testImpersonateQueryParamCsrfAttackPrevented(): void { @@ -2803,7 +2803,7 @@ trait UsersBase $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); - // Legitimate use 2: same-site (Console on subdomain, e.g. vibes.appwrite.io vs appwrite.io) + // Legitimate use 2: same-site (Console on a different subdomain than the API) $sameSite = $this->client->call( Client::METHOD_GET, '/account', @@ -2854,7 +2854,7 @@ trait UsersBase $sessionSecret = $session['body']['secret']; // Query param works when Sec-Fetch-Site is same-origin or same-site. - // same-site covers Console deployed on a subdomain (e.g. vibes.appwrite.io). + // same-site covers Console deployed on a different subdomain than the API. $account = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From f0cbfbbbe4fc4b157844c9af67b1b29c7e56a16a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 14:31:49 +0530 Subject: [PATCH 32/56] fix: use assertEmpty for impersonatorUserId to match response model --- tests/e2e/Services/Users/UsersBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index d5c06e9f8d..70e74648b4 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2778,7 +2778,7 @@ trait UsersBase ); $this->assertEquals(200, $crossSite['headers']['status-code']); $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); - $this->assertArrayNotHasKey('impersonatorUserId', $crossSite['body']); + $this->assertEmpty($crossSite['body']['impersonatorUserId'] ?? ''); // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. @@ -2790,7 +2790,7 @@ trait UsersBase ); $this->assertEquals(200, $noFetchSite['headers']['status-code']); $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); - $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); + $this->assertEmpty($noFetchSite['body']['impersonatorUserId'] ?? ''); // Legitimate use 1: same-origin (Console on same origin as API) $sameOrigin = $this->client->call( @@ -2915,7 +2915,7 @@ trait UsersBase $this->assertEquals(200, $account['headers']['status-code']); // Should resolve as userA (the impersonator), not the target $this->assertEquals($idA, $account['body']['$id']); - $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); } /** @@ -2965,7 +2965,7 @@ trait UsersBase ], ['impersonateUserId' => $idB]); $this->assertEquals(200, $account['headers']['status-code']); $this->assertEquals($idA, $account['body']['$id']); - $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); } /** From 8f176166c9f1e24eb7d7bf2124e2f725bbcf5b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 15:31:10 +0200 Subject: [PATCH 33/56] Re-introduce project JWT endpoint --- app/controllers/api/projects.php | 44 ++++++++++++++++ .../Projects/ProjectsConsoleClientTest.php | 52 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 494aa11150..da772d6dbb 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,5 +1,6 @@ dynamic($project, Response::MODEL_PROJECT); }); +// JWT Keys + +Http::post('/v1/projects/:projectId/jwts') + ->groups(['api', 'projects']) + ->desc('Create JWT') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'createJWT', + description: '/docs/references/projects/create-jwt.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_JWT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ])]), Response::MODEL_JWT); + }); + // Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 8322e37de1..d71537d534 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3941,6 +3941,58 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']); } + // JWT Keys + + public function testJWTKey(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + // Create JWT key + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'duration' => 5, + 'scopes' => ['users.read'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + + $jwt = $response['body']['jwt']; + + // Ensure JWT key works + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('users', $response['body']); + + // Ensure JWT key respect scopes + $response = $this->client->call(Client::METHOD_GET, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Ensure JWT key expires + \sleep(10); + + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + // Platforms public function testCreateProjectPlatform(): void From 87ed7c3817c1878eb900bc3f0bd30fbf4451122c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 19:10:55 +0530 Subject: [PATCH 34/56] feat: add query param fallback for all impersonation params and simplify tests --- app/init/realtime/connection.php | 17 +- app/init/resources/request.php | 17 +- tests/e2e/Services/Users/UsersBase.php | 245 ++++--------------------- 3 files changed, 48 insertions(+), 231 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 03dfdc4fd7..a090635bb5 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,18 +327,11 @@ return function (Container $container): void { } } - // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. - // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. - // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via - // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. - $fetchSite = $request->getHeader('sec-fetch-site', ''); - // Allow same-origin and same-site: Console may be served from a different subdomain - // than the API, in which case the browser sends same-site. - // cross-site and absent are blocked to prevent CSRF via third-party embeds. - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index c0097a2416..1aa53b7403 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -572,18 +572,11 @@ return function (Container $container): void { } // Impersonation: if current user has impersonator capability and headers/params are set, act as another user - // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) - // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. - // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; - // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. - $fetchSite = $request->getHeader('sec-fetch-site', ''); - // Allow same-origin and same-site: Console may be served from a different subdomain - // than the API, in which case the browser sends same-site. - // cross-site and absent are blocked to prevent CSRF via third-party embeds. - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 70e74648b4..f9db65369a 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2709,118 +2709,10 @@ trait UsersBase } /** - * Proves that the Sec-Fetch-Site CSRF guard prevents forced impersonation via query params. - * - * Attack scenario (without the guard): - * A malicious page on attacker.com embeds: - * - * The browser automatically attaches the impersonator's session cookies. - * Without any guard, the server would impersonate victim_id silently. - * - * Why Sec-Fetch-Site works: - * Browsers set Sec-Fetch-Site: cross-site on all cross-origin requests (img, fetch, etc.). - * It is a browser-enforced forbidden header — JavaScript cannot set or spoof it. - * We only accept ?impersonateUserId when Sec-Fetch-Site is exactly same-origin. - * - * This test proves two attack vectors are blocked and two legitimate origins are allowed: - * Blocked: cross-site — attacker.com embeds pointing at Appwrite - * Blocked: absent — reverse proxy strips Fetch Metadata headers (fail-closed) - * Allowed: same-origin — Console on the same origin as the API - * Allowed: same-site — Console on a different subdomain than the API + * Test impersonation via URL query params — mirrors the ?project= and ?devKey= pattern. + * Allows Console to embed impersonation in direct file/image URLs where headers cannot be set. */ - public function testImpersonateQueryParamCsrfAttackPrevented(): void - { - $projectId = $this->getProject()['$id']; - $headers = array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()); - - // Impersonator user (the victim whose session gets hijacked in the attack) - $impersonator = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-guard-impersonator@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Guard Impersonator', - ]); - $this->assertEquals(201, $impersonator['headers']['status-code']); - $impersonatorId = $impersonator['body']['$id']; - - // Target user (who the attacker wants to impersonate) - $target = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-guard-target@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Guard Target', - ]); - $this->assertEquals(201, $target['headers']['status-code']); - $targetId = $target['body']['$id']; - - $this->client->call(Client::METHOD_PATCH, '/users/' . $impersonatorId . '/impersonator', $headers, ['impersonator' => true]); - - $session = $this->client->call(Client::METHOD_POST, '/users/' . $impersonatorId . '/sessions', $headers); - $this->assertEquals(201, $session['headers']['status-code']); - $sessionSecret = $session['body']['secret']; - - $sessionHeaders = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-session' => $sessionSecret, - ]; - - // Attack vector 1: cross-site (attacker.com embeds ) - // Browser sends Sec-Fetch-Site: cross-site — must be blocked. - $crossSite = $this->client->call( - Client::METHOD_GET, - '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'cross-site']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $crossSite['headers']['status-code']); - $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); - $this->assertEmpty($crossSite['body']['impersonatorUserId'] ?? ''); - - // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) - // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. - $noFetchSite = $this->client->call( - Client::METHOD_GET, - '/account', - $sessionHeaders, - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $noFetchSite['headers']['status-code']); - $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); - $this->assertEmpty($noFetchSite['body']['impersonatorUserId'] ?? ''); - - // Legitimate use 1: same-origin (Console on same origin as API) - $sameOrigin = $this->client->call( - Client::METHOD_GET, - '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $sameOrigin['headers']['status-code']); - $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); - $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); - - // Legitimate use 2: same-site (Console on a different subdomain than the API) - $sameSite = $this->client->call( - Client::METHOD_GET, - '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $sameSite['headers']['status-code']); - $this->assertEquals($targetId, $sameSite['body']['$id'], 'same-site: impersonation must succeed'); - $this->assertEquals($impersonatorId, $sameSite['body']['impersonatorUserId']); - } - - /** - * Test impersonation via ?impersonateUserId= query param (same-origin browser request). - * This is the primary use case for embedding impersonation in file/image URLs where - * custom headers cannot be set (e.g. , deployment source/output download links). - */ - public function testImpersonateByUserIdQueryParam(): void + public function testImpersonateByQueryParams(): void { $projectId = $this->getProject()['$id']; $headers = array_merge([ @@ -2853,119 +2745,58 @@ trait UsersBase $this->assertEquals(201, $session['headers']['status-code']); $sessionSecret = $session['body']['secret']; - // Query param works when Sec-Fetch-Site is same-origin or same-site. - // same-site covers Console deployed on a different subdomain than the API. - $account = $this->client->call(Client::METHOD_GET, '/account', [ + $sessionHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'x-appwrite-session' => $sessionSecret, - 'sec-fetch-site' => 'same-origin', - ], ['impersonateUserId' => $idB]); + ]; + + // Impersonate by user ID via query param + $account = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateUserId' => $idB, + ]); $this->assertEquals(200, $account['headers']['status-code']); $this->assertEquals($idB, $account['body']['$id']); $this->assertEquals('Query Param Target', $account['body']['name']); $this->assertEquals($idA, $account['body']['impersonatorUserId']); - } - /** - * Test that ?impersonateUserId= query param is ignored for cross-site requests (CSRF guard). - * Sec-Fetch-Site is a browser-enforced forbidden header; cross-site value means the request - * originated from a third-party page and must not be allowed to trigger impersonation. - */ - public function testImpersonateQueryParamIgnoredCrossSite(): void - { - $projectId = $this->getProject()['$id']; - $headers = array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()); - - $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-impersonator@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Impersonator', + // Impersonate by email via query param + $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateEmail' => 'queryparam-target@appwrite.io', ]); - $this->assertEquals(201, $userA['headers']['status-code']); - $idA = $userA['body']['$id']; + $this->assertEquals(200, $accountByEmail['headers']['status-code']); + $this->assertEquals($idB, $accountByEmail['body']['$id']); + $this->assertEquals($idA, $accountByEmail['body']['impersonatorUserId']); - $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-target@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Target', + // Impersonate by phone via query param (update target user with a phone first) + $this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [ + 'number' => '+12345678901', ]); - $this->assertEquals(201, $userB['headers']['status-code']); - $idB = $userB['body']['$id']; - - $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); - $this->assertEquals(200, $patch['headers']['status-code']); - - $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); - $this->assertEquals(201, $session['headers']['status-code']); - $sessionSecret = $session['body']['secret']; - - // Query param must be ignored when Sec-Fetch-Site is cross-site (third-party page embed) - $account = $this->client->call(Client::METHOD_GET, '/account', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-session' => $sessionSecret, - 'sec-fetch-site' => 'cross-site', - ], ['impersonateUserId' => $idB]); - $this->assertEquals(200, $account['headers']['status-code']); - // Should resolve as userA (the impersonator), not the target - $this->assertEquals($idA, $account['body']['$id']); - $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); - } - - /** - * Test that ?impersonateUserId= query param is ignored when Sec-Fetch-Site is absent - * (fail-closed CSRF guard). Absent header means a reverse proxy stripped Fetch Metadata - * headers or a non-browser client is calling — query param must be silently ignored. - */ - public function testImpersonateQueryParamIgnoredWhenSecFetchSiteAbsent(): void - { - $projectId = $this->getProject()['$id']; - $headers = array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()); - - $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'absent-fetch-impersonator@appwrite.io', - 'password' => 'password', - 'name' => 'Absent Fetch Impersonator', + $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonatePhone' => '+12345678901', ]); - $this->assertEquals(201, $userA['headers']['status-code']); - $idA = $userA['body']['$id']; + $this->assertEquals(200, $accountByPhone['headers']['status-code']); + $this->assertEquals($idB, $accountByPhone['body']['$id']); + $this->assertEquals($idA, $accountByPhone['body']['impersonatorUserId']); - $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + // Header takes priority over query param when both are present + $userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'absent-fetch-target@appwrite.io', + 'email' => 'queryparam-target-c@appwrite.io', 'password' => 'password', - 'name' => 'Absent Fetch Target', + 'name' => 'Query Param Target C', ]); - $this->assertEquals(201, $userB['headers']['status-code']); - $idB = $userB['body']['$id']; + $this->assertEquals(201, $userC['headers']['status-code']); + $idC = $userC['body']['$id']; - $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); - $this->assertEquals(200, $patch['headers']['status-code']); - - $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); - $this->assertEquals(201, $session['headers']['status-code']); - $sessionSecret = $session['body']['secret']; - - // Query param must be ignored when Sec-Fetch-Site is absent (proxy-stripped or API client) - $account = $this->client->call(Client::METHOD_GET, '/account', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-session' => $sessionSecret, - // no sec-fetch-site header - ], ['impersonateUserId' => $idB]); - $this->assertEquals(200, $account['headers']['status-code']); - $this->assertEquals($idA, $account['body']['$id']); - $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); + $accountHeaderPriority = $this->client->call( + Client::METHOD_GET, + '/account', + array_merge($sessionHeaders, ['x-appwrite-impersonate-user-id' => $idC]), + ['impersonateUserId' => $idB] + ); + $this->assertEquals(200, $accountHeaderPriority['headers']['status-code']); + $this->assertEquals($idC, $accountHeaderPriority['body']['$id'], 'header must take priority over query param'); } /** From 2a357511eacc6f843c560541f175ff53443cf8b3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 19:17:12 +0530 Subject: [PATCH 35/56] fix: use unique emails and phone in query param impersonation test --- tests/e2e/Services/Users/UsersBase.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index f9db65369a..b06e2d88e1 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2720,9 +2720,14 @@ trait UsersBase 'x-appwrite-project' => $projectId, ], $this->getHeaders()); + $emailA = 'queryparam-impersonator-' . \uniqid() . '@appwrite.io'; + $emailB = 'queryparam-target-' . \uniqid() . '@appwrite.io'; + $emailC = 'queryparam-target-c-' . \uniqid() . '@appwrite.io'; + $phone = '+1' . \rand(1000000000, 9999999999); + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'queryparam-impersonator@appwrite.io', + 'email' => $emailA, 'password' => 'password', 'name' => 'Query Param Impersonator', ]); @@ -2731,7 +2736,7 @@ trait UsersBase $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'queryparam-target@appwrite.io', + 'email' => $emailB, 'password' => 'password', 'name' => 'Query Param Target', ]); @@ -2762,7 +2767,7 @@ trait UsersBase // Impersonate by email via query param $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ - 'impersonateEmail' => 'queryparam-target@appwrite.io', + 'impersonateEmail' => $emailB, ]); $this->assertEquals(200, $accountByEmail['headers']['status-code']); $this->assertEquals($idB, $accountByEmail['body']['$id']); @@ -2770,10 +2775,10 @@ trait UsersBase // Impersonate by phone via query param (update target user with a phone first) $this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [ - 'number' => '+12345678901', + 'number' => $phone, ]); $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ - 'impersonatePhone' => '+12345678901', + 'impersonatePhone' => $phone, ]); $this->assertEquals(200, $accountByPhone['headers']['status-code']); $this->assertEquals($idB, $accountByPhone['body']['$id']); @@ -2782,7 +2787,7 @@ trait UsersBase // Header takes priority over query param when both are present $userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'queryparam-target-c@appwrite.io', + 'email' => $emailC, 'password' => 'password', 'name' => 'Query Param Target C', ]); From ed9b47f6ce7d8aff0d1962df7f1e65a293ac1e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 15:57:37 +0200 Subject: [PATCH 36/56] Migrate project jwt to dynamic api key --- app/controllers/api/projects.php | 42 ------- .../Http/Project/Keys/Dynamic/Create.php | 115 ++++++++++++++++++ .../Project/Keys/{ => Standard}/Create.php | 15 ++- .../Projects/ProjectsConsoleClientTest.php | 2 + 4 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php rename src/Appwrite/Platform/Modules/Project/Http/Project/Keys/{ => Standard}/Create.php (88%) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index da772d6dbb..ca7f8bb216 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -60,48 +60,6 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -// JWT Keys - -Http::post('/v1/projects/:projectId/jwts') - ->groups(['api', 'projects']) - ->desc('Create JWT') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'createJWT', - description: '/docs/references/projects/create-jwt.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_JWT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ])]), Response::MODEL_JWT); - }); - // Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php new file mode 100644 index 0000000000..2df1f2a303 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php @@ -0,0 +1,115 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys/dynamic') + ->httpAlias('/v1/projects/:projectId/jwts') + ->desc('Create dynamic project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createDynamicKey', + description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) + ->param('duration', 900, new Range(1, 3600), 'Time in seconds before dynamic key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + array $scopes, + int $duration, + Response $response, + QueueEvent $queueForEvents, + Document $project, + ) { + $keyId = ID::unique(); + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $secret = $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ]); + + $now = new \DateTime(); + $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); + + $key = new Document([ + '$id' => $keyId, + '$createdAt' => new DatabaseDateTime(), + '$updatedAt' => new DatabaseDateTime(), + 'name' => '', + 'scopes' => $scopes, + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_DYNAMIC . '_' . $secret, + ]); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php similarity index 88% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php index 236c091c31..ccf19e4a30 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/project/keys') + ->setHttpPath('/v1/project/keys/standard') + ->httpAlias('/v1/project/keys') ->httpAlias('/v1/projects/:projectId/keys') - ->desc('Create project key') + ->desc('Create standard project key') ->groups(['api', 'project']) ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].create') @@ -48,9 +49,11 @@ class Create extends Base ->label('sdk', new Method( namespace: 'project', group: 'keys', - name: 'createKey', + name: 'createStandardKey', description: <<assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['jwt']); + $this->assertNotEmpty($response['body']['projectId']); + $this->assertSame($id, $response['body']['projectId']); $jwt = $response['body']['jwt']; From b2ce95a0cd6ec246067311537cbf2e4bf9437a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:14:10 +0200 Subject: [PATCH 37/56] Dynamic key backwards compatibility --- app/controllers/api/projects.php | 2 -- app/controllers/general.php | 8 +++++ app/init/constants.php | 4 +-- app/init/models.php | 2 ++ src/Appwrite/Migration/Migration.php | 1 + .../Http/Project/Keys/Dynamic/Create.php | 32 +++++++---------- src/Appwrite/Utopia/Request/Filters/V24.php | 14 ++++++++ src/Appwrite/Utopia/Response.php | 1 + src/Appwrite/Utopia/Response/Filters/V24.php | 36 +++++++++++++++++++ .../Utopia/Response/Model/DynamicKey.php | 33 +++++++++++++++++ src/Appwrite/Utopia/Response/Model/Key.php | 5 --- 11 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V24.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V24.php create mode 100644 src/Appwrite/Utopia/Response/Model/DynamicKey.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index ca7f8bb216..494aa11150 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,6 +1,5 @@ addFilter(new RequestV23()); } + if (version_compare($requestFormat, '1.9.3', '<')) { + $request->addFilter(new RequestV24()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -923,6 +928,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.3', '<')) { + $response->addFilter(new ResponseV24()); + } if (version_compare($responseFormat, '1.9.2', '<')) { $response->addFilter(new ResponseV23()); } diff --git a/app/init/constants.php b/app/init/constants.php index 8eacf2fe12..c3f67502f2 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4323; -const APP_VERSION_STABLE = '1.9.2'; +const APP_CACHE_BUSTER = 4324; +const APP_VERSION_STABLE = '1.9.3'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/init/models.php b/app/init/models.php index 77ca9be451..699d1561a3 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -70,6 +70,7 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; +use Appwrite\Utopia\Response\Model\DynamicKey; use Appwrite\Utopia\Response\Model\Embedding; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; @@ -392,6 +393,7 @@ Response::setModel(new Execution()); Response::setModel(new Project()); Response::setModel(new Webhook()); Response::setModel(new Key()); +Response::setModel(new DynamicKey()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index ef0dd9f8b5..359925e368 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -95,6 +95,7 @@ abstract class Migration '1.9.0' => 'V24', '1.9.1' => 'V24', '1.9.2' => 'V24', + '1.9.3' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php index 2df1f2a303..eaad5a8c64 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php @@ -4,28 +4,20 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys\Dynami; use Ahc\Jwt\JWT; use Appwrite\Event\Event as QueueEvent; -use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Datetime; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; use Utopia\Validator\ArrayList; -use Utopia\Validator\Nullable; use Utopia\Validator\Range; -use Utopia\Validator\Text; use Utopia\Validator\WhiteList; class Create extends Base @@ -62,7 +54,7 @@ class Create extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_KEY, + model: Response::MODEL_DYNAMIC_KEY, ) ], )) @@ -84,16 +76,16 @@ class Create extends Base ) { $keyId = ID::unique(); - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $secret = $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ]); - - $now = new \DateTime(); - $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); - + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $secret = $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ]); + + $now = new \DateTime(); + $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); + $key = new Document([ '$id' => $keyId, '$createdAt' => new DatabaseDateTime(), @@ -110,6 +102,6 @@ class Create extends Base $response ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_KEY); + ->dynamic($key, Response::MODEL_DYNAMIC_KEY); } } diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php new file mode 100644 index 0000000000..29df762f28 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -0,0 +1,14 @@ + $this->parseDynamicKey($content), + default => $content, + }; + } + + private function parseDynamicKey(array $content): array + { + unset($content['$id']); + unset($content['$createdAt']); + unset($content['$updatedAt']); + unset($content['name']); + unset($content['expire']); + unset($content['sdks']); + unset($content['accessedAt']); + + $content['jwt'] = $content['secret'] ?? ''; + unset($content['secret']); + + $content['projectId'] = 'WHAT DO I DO NOW?!'; + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/DynamicKey.php b/src/Appwrite/Utopia/Response/Model/DynamicKey.php new file mode 100644 index 0000000000..c1016f3fcc --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/DynamicKey.php @@ -0,0 +1,33 @@ + Date: Tue, 28 Apr 2026 16:18:36 +0200 Subject: [PATCH 38/56] Bug&test fixing --- .../Modules/Project/Http/Project/Keys/Dynamic/Create.php | 7 +++---- src/Appwrite/Platform/Modules/Project/Services/Http.php | 6 ++++-- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php index eaad5a8c64..8839a146fb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php @@ -1,6 +1,6 @@ $keyId, - '$createdAt' => new DatabaseDateTime(), - '$updatedAt' => new DatabaseDateTime(), + '$createdAt' => DatabaseDateTime::now(), + '$updatedAt' => DatabaseDateTime::now(), 'name' => '', 'scopes' => $scopes, 'expire' => $expire, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 8c6b9da7e7..a0b2cd2acf 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -5,9 +5,10 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; -use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Dynamic\Create as CreateDynamicKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard\Create as CreateStandardKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; @@ -130,7 +131,8 @@ class Http extends Service $this->addAction(UpdateVariable::getName(), new UpdateVariable()); // Keys - $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(CreateStandardKey::getName(), new CreateStandardKey()); + $this->addAction(CreateDynamicKey::getName(), new CreateDynamicKey()); $this->addAction(ListKeys::getName(), new ListKeys()); $this->addAction(GetKey::getName(), new GetKey()); $this->addAction(DeleteKey::getName(), new DeleteKey()); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 435b80ffd6..6936de9aff 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3952,6 +3952,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.2', ], $this->getHeaders()), [ 'duration' => 5, 'scopes' => ['users.read'], From 11f80fc2edc8faf8b9ca33e2fb3a85414ae3093a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:35:40 +0200 Subject: [PATCH 39/56] Solve key projectId backwards compatibility --- src/Appwrite/Utopia/Response/Filters/V24.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php index 685e6d81bf..8ac5305dc1 100644 --- a/src/Appwrite/Utopia/Response/Filters/V24.php +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -26,11 +26,22 @@ class V24 extends Filter unset($content['sdks']); unset($content['accessedAt']); + $projectId = ''; + if (isset($content['secret'])) { + $parts = explode('_', $content['secret'], 2); + if (count($parts) === 2) { + $jwtParts = explode('.', $parts[1]); + if (count($jwtParts) >= 2) { + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $jwtParts[1])), true); + $projectId = $payload['projectId'] ?? ''; + } + } + } + $content['projectId'] = $projectId; + $content['jwt'] = $content['secret'] ?? ''; unset($content['secret']); - $content['projectId'] = 'WHAT DO I DO NOW?!'; - return $content; } } From 72dfd8a7bc2c474e3b76186550edeb872e028bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:45:00 +0200 Subject: [PATCH 40/56] Add E2E tests for dynamic keys --- tests/e2e/Services/Project/KeysBase.php | 131 ++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 505c7f6539..5019c8fefd 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -239,6 +239,112 @@ trait KeysBase $this->deleteKey($customId); } + // ========================================================================= + // Create dynamic key tests + // ========================================================================= + + public function testCreateDynamicKey(): void + { + $key = $this->createDynamicKey( + ['users.read', 'users.write'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertStringStartsWith(API_KEY_DYNAMIC . '_', $key['body']['secret']); + $this->assertSame([], $key['body']['sdks']); + $this->assertNull($key['body']['accessedAt']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['expire'])); + + // Verify JWT payload + $jwt = substr($key['body']['secret'], strlen(API_KEY_DYNAMIC . '_')); + $parts = explode('.', $jwt); + $this->assertCount(3, $parts); + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); + $this->assertNotEmpty($payload['projectId']); + $this->assertSame(['users.read', 'users.write'], $payload['scopes']); + + // Verify default duration (900 seconds) + $expireDt = new \DateTime($key['body']['expire']); + $now = new \DateTime(); + $diff = $expireDt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThanOrEqual(890, $diff); + $this->assertLessThanOrEqual(910, $diff); + } + + public function testCreateDynamicKeyWithDuration(): void + { + $duration = 1800; + + $key = $this->createDynamicKey( + ['databases.read'], + $duration, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame(['databases.read'], $key['body']['scopes']); + + $expireDt = new \DateTime($key['body']['expire']); + $now = new \DateTime(); + $diff = $expireDt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThanOrEqual($duration - 10, $diff); + $this->assertLessThanOrEqual($duration + 10, $diff); + } + + public function testCreateDynamicKeyWithEmptyScopes(): void + { + $key = $this->createDynamicKey( + [], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + } + + public function testCreateDynamicKeyWithoutAuthentication(): void + { + $response = $this->createDynamicKey( + ['users.read'], + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateDynamicKeyInvalidScope(): void + { + $response = $this->createDynamicKey( + ['invalid.scope'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateDynamicKeyInvalidDuration(): void + { + $response = $this->createDynamicKey( + ['users.read'], + 0, + ); + + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createDynamicKey( + ['users.read'], + 3601, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + // ========================================================================= // Update key tests // ========================================================================= @@ -855,4 +961,29 @@ trait KeysBase return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers); } + + /** + * @param array $scopes + */ + protected function createDynamicKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed + { + $params = [ + 'scopes' => $scopes, + ]; + + if ($duration !== null) { + $params['duration'] = $duration; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/keys/dynamic', $headers, $params); + } } From f5a732d2311e9614e9496540924ace457a199b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:47:39 +0200 Subject: [PATCH 41/56] Add dynami key integration test --- .../Services/Project/KeysIntegrationTest.php | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/e2e/Services/Project/KeysIntegrationTest.php diff --git a/tests/e2e/Services/Project/KeysIntegrationTest.php b/tests/e2e/Services/Project/KeysIntegrationTest.php new file mode 100644 index 0000000000..2615cac023 --- /dev/null +++ b/tests/e2e/Services/Project/KeysIntegrationTest.php @@ -0,0 +1,103 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $consoleHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-mode' => 'admin', + 'x-appwrite-project' => $projectId, + ]; + + // Step 1: Create a dynamic key scoped to users.read only. + $dynamicKey = $this->client->call( + Client::METHOD_POST, + '/project/keys/dynamic', + $serverHeaders, + [ + 'scopes' => ['users.read'], + 'duration' => 900, + ] + ); + $this->assertSame(201, $dynamicKey['headers']['status-code']); + $this->assertNotEmpty($dynamicKey['body']['secret']); + + $dynamicKeySecret = $dynamicKey['body']['secret']; + + $dynamicHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $dynamicKeySecret, + ]; + + // Step 2: Create a project user using console headers. + $user = $this->client->call( + Client::METHOD_POST, + '/users', + $consoleHeaders, + [ + 'userId' => ID::unique(), + 'email' => 'dynamic_key_' . \uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Dynamic Key Test User', + ] + ); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Step 3: Dynamic key can list users. + $list = $this->client->call( + Client::METHOD_GET, + '/users', + $dynamicHeaders + ); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Step 4: Dynamic key can get the specific user. + $get = $this->client->call( + Client::METHOD_GET, + '/users/' . $userId, + $dynamicHeaders + ); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($userId, $get['body']['$id']); + + // Step 5: Dynamic key cannot create users (missing users.write scope). + $createAttempt = $this->client->call( + Client::METHOD_POST, + '/users', + $dynamicHeaders, + [ + 'userId' => ID::unique(), + 'email' => 'should_fail_' . \uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Should Fail', + ] + ); + $this->assertSame(401, $createAttempt['headers']['status-code']); + } +} From 3f5dcc81fd27a71066e3d689b1e4c56063c47aff Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 15:57:41 +0100 Subject: [PATCH 42/56] Refactor migrations API to module style --- app/config/services.php | 2 +- app/controllers/api/migrations.php | 1277 ----------------- src/Appwrite/Platform/Appwrite.php | 2 + .../Http/Migrations/Appwrite/Create.php | 110 ++ .../Http/Migrations/Appwrite/Report/Get.php | 80 ++ .../Http/Migrations/CSV/Exports/Create.php | 213 +++ .../Http/Migrations/CSV/Imports/Create.php | 220 +++ .../Migrations/Http/Migrations/Delete.php | 74 + .../Http/Migrations/Firebase/Create.php | 114 ++ .../Http/Migrations/Firebase/Report/Get.php | 80 ++ .../Migrations/Http/Migrations/Get.php | 61 + .../Http/Migrations/JSON/Exports/Create.php | 198 +++ .../Http/Migrations/JSON/Imports/Create.php | 221 +++ .../Http/Migrations/NHost/Create.php | 122 ++ .../Http/Migrations/NHost/Report/Get.php | 86 ++ .../Http/Migrations/Supabase/Create.php | 120 ++ .../Http/Migrations/Supabase/Report/Get.php | 85 ++ .../Migrations/Http/Migrations/Update.php | 90 ++ .../Migrations/Http/Migrations/XList.php | 104 ++ .../Platform/Modules/Migrations/Module.php | 14 + .../Modules/Migrations/Services/Http.php | 59 + 21 files changed, 2054 insertions(+), 1278 deletions(-) delete mode 100644 app/controllers/api/migrations.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Module.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Services/Http.php diff --git a/app/config/services.php b/app/config/services.php index 548f659a81..cf2714f8c5 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -286,7 +286,7 @@ return [ 'name' => 'Migrations', 'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite project.', 'description' => '/docs/services/migrations.md', - 'controller' => 'api/migrations.php', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php deleted file mode 100644 index 7338197511..0000000000 --- a/app/controllers/api/migrations.php +++ /dev/null @@ -1,1277 +0,0 @@ - Transfer::GROUP_DATABASES_TABLES_DB, - DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, - default => throw new \LogicException('Unknown database type: ' . $databaseType), - }; -} - -function getDatabaseResourceType(string $databaseType): string -{ - return match($databaseType) { - DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, - DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, - default => Resource::TYPE_DATABASE, - }; -} - -Http::post('/v1/migrations/appwrite') - ->groups(['api', 'migrations']) - ->desc('Create Appwrite migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createAppwriteMigration', - description: '/docs/references/migrations/migration-appwrite.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') - ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) - ->param('apiKey', '', new Text(512), 'Source API Key') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'endpoint' => $endpoint, - 'projectId' => $projectId, - 'apiKey' => $apiKey, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/firebase') - ->groups(['api', 'migrations']) - ->desc('Create Firebase migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createFirebaseMigration', - description: '/docs/references/migrations/migration-firebase.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') - ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $serviceAccountData = json_decode($serviceAccount, true); - - if (empty($serviceAccountData)) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Firebase::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'serviceAccount' => $serviceAccount, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/supabase') - ->groups(['api', 'migrations']) - ->desc('Create Supabase migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createSupabaseMigration', - description: '/docs/references/migrations/migration-supabase.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') - ->param('apiKey', '', new Text(512), 'Source\'s API Key') - ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') - ->param('username', '', new Text(512), 'Source\'s Database Username') - ->param('password', '', new Text(512), 'Source\'s Database Password') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Supabase::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'endpoint' => $endpoint, - 'apiKey' => $apiKey, - 'databaseHost' => $databaseHost, - 'username' => $username, - 'password' => $password, - 'port' => $port, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/nhost') - ->groups(['api', 'migrations']) - ->desc('Create NHost migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createNHostMigration', - description: '/docs/references/migrations/migration-nhost.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') - ->param('subdomain', '', new Text(512), 'Source\'s Subdomain') - ->param('region', '', new Text(512), 'Source\'s Region') - ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') - ->param('database', '', new Text(512), 'Source\'s Database Name') - ->param('username', '', new Text(512), 'Source\'s Database Username') - ->param('password', '', new Text(512), 'Source\'s Database Password') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => NHost::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'subdomain' => $subdomain, - 'region' => $region, - 'adminSecret' => $adminSecret, - 'database' => $database, - 'username' => $username, - 'password' => $password, - 'port' => $port, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/csv/imports') - ->alias('/v1/migrations/csv') - ->groups(['api', 'migrations']) - ->desc('Import documents from a CSV') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createCSVImport', - description: '/docs/references/migrations/migration-csv-import.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject']) - ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') - ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('deviceForFiles') - ->inject('deviceForMigrations') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $bucketId, - string $fileId, - string $resourceId, - bool $internalFile, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Device $deviceForFiles, - Device $deviceForMigrations, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { - if ($internalFile) { - return $dbForPlatform->getDocument('buckets', 'default'); - } - return $dbForProject->getDocument('buckets', $bucketId); - }); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - // No encryption or compression on files above 20MB. - $hasEncryption = !empty($file->getAttribute('openSSLCipher')); - $compression = $file->getAttribute('algorithm', Compression::NONE); - $hasCompression = $compression !== Compression::NONE; - - $migrationId = ID::unique(); - $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv'); - - if ($hasEncryption || $hasCompression) { - $source = $deviceForFiles->read($path); - - if ($hasEncryption) { - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - hex2bin($file->getAttribute('openSSLIV')), - hex2bin($file->getAttribute('openSSLTag')) - ); - } - - if ($hasCompression) { - switch ($compression) { - case Compression::ZSTD: - $source = (new Zstd())->decompress($source); - break; - case Compression::GZIP: - $source = (new GZIP())->decompress($source); - break; - } - } - - // Manual write after decryption and/or decompression - if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) { - throw new \Exception('Unable to copy file'); - } - } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { - throw new \Exception('Unable to copy file'); - } - - // getting databasetype - $resources = explode(':', $resourceId); - $databaseId = $resources[0]; - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $databaseType = $database->getAttribute('type'); - if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { - throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); - } - $fileSize = $deviceForMigrations->getFileSize($newPath); - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => $migrationId, - 'status' => 'pending', - 'stage' => 'init', - 'source' => CSV::getName(), - 'destination' => Appwrite::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'path' => $newPath, - 'size' => $fileSize, - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/csv/exports') - ->groups(['api', 'migrations']) - ->desc('Export documents to CSV') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createCSVExport', - description: '/docs/references/migrations/migration-csv-export.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') - ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) - ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true) - ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true) - ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true) - ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true) - ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) - ->inject('user') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $resourceId, - string $filename, - array $columns, - array $queries, - string $delimiter, - string $enclosure, - string $escape, - bool $header, - bool $notify, - Document $user, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - try { - $parsedQueries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - [$databaseId, $collectionId] = \explode(':', $resourceId, 2); - if (empty($databaseId)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - if (empty($collectionId)) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - // getting databasetype - $resources = explode(':', $resourceId); - $databaseId = $resources[0]; - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $databaseType = $database->getAttribute('type'); - if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { - throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); - } - - // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields - $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); - - $validator = new Documents( - attributes: $collection->getAttribute('attributes', []), - indexes: $collection->getAttribute('indexes', []), - idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), - supportForAttributes: !$isSchemaless, - ); - - if (!$validator->isValid($parsedQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => CSV::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'bucketId' => 'default', // Always use internal bucket - 'filename' => $filename, - 'columns' => $columns, - 'queries' => $queries, - 'delimiter' => $delimiter, - 'enclosure' => $enclosure, - 'escape' => $escape, - 'header' => $header, - 'notify' => $notify, - 'userInternalId' => $user->getSequence(), - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/json/imports') - ->groups(['api', 'migrations']) - ->desc('Import documents from a JSON') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createJSONImport', - description: '/docs/references/migrations/migration-json-import.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') - ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('deviceForFiles') - ->inject('deviceForMigrations') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $bucketId, - string $fileId, - string $resourceId, - bool $internalFile, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Device $deviceForFiles, - Device $deviceForMigrations, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { - if ($internalFile) { - return $dbForPlatform->getDocument('buckets', 'default'); - } - return $dbForProject->getDocument('buckets', $bucketId); - }); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - // No encryption or compression on files above 20MB. - $hasEncryption = !empty($file->getAttribute('openSSLCipher')); - $compression = $file->getAttribute('algorithm', Compression::NONE); - $hasCompression = $compression !== Compression::NONE; - - $migrationId = ID::unique(); - $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); - - if ($hasEncryption || $hasCompression) { - $source = $deviceForFiles->read($path); - - if ($hasEncryption) { - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - hex2bin($file->getAttribute('openSSLIV')), - hex2bin($file->getAttribute('openSSLTag')) - ); - } - - if ($hasCompression) { - switch ($compression) { - case Compression::ZSTD: - $source = (new Zstd())->decompress($source); - break; - case Compression::GZIP: - $source = (new GZIP())->decompress($source); - break; - } - } - - // Manual write after decryption and/or decompression - if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { - throw new \Exception('Unable to copy file'); - } - } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { - throw new \Exception('Unable to copy file'); - } - - $fileSize = $deviceForMigrations->getFileSize($newPath); - - [$databaseId] = \explode(':', $resourceId, 2); - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - $databaseType = $database->getAttribute('type'); - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => $migrationId, - 'status' => 'pending', - 'stage' => 'init', - 'source' => JSON::getName(), - 'destination' => Appwrite::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'path' => $newPath, - 'size' => $fileSize, - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/json/exports') - ->groups(['api', 'migrations']) - ->desc('Export documents to JSON') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createJSONExport', - description: '/docs/references/migrations/migration-json-export.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') - ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) - ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) - ->inject('user') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $resourceId, - string $filename, - array $columns, - array $queries, - bool $notify, - Document $user, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - try { - $parsedQueries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - [$databaseId, $collectionId] = \explode(':', $resourceId, 2); - if (empty($databaseId)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - if (empty($collectionId)) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $databaseType = $database->getAttribute('type'); - - // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields - $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); - - $validator = new Documents( - attributes: $collection->getAttribute('attributes', []), - indexes: $collection->getAttribute('indexes', []), - idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), - supportForAttributes: !$isSchemaless, - ); - - if (!$validator->isValid($parsedQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => JSON::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'bucketId' => 'default', // Always use internal bucket - 'filename' => $filename, - 'columns' => $columns, - 'queries' => $queries, - 'notify' => $notify, - 'userInternalId' => $user->getSequence(), - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::get('/v1/migrations') - ->groups(['api', 'migrations']) - ->desc('List migrations') - ->label('scope', 'migrations.read') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'list', - description: '/docs/references/migrations/list-migrations.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_LIST, - ) - ] - )) - ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::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()); - } - - $migrationId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - try { - $migrations = $dbForProject->find('migrations', $queries); - $total = $includeTotal ? $dbForProject->count('migrations', $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([ - 'migrations' => $migrations, - 'total' => $total, - ]), Response::MODEL_MIGRATION_LIST); - }); - -Http::get('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Get migration') - ->label('scope', 'migrations.read') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'get', - description: '/docs/references/migrations/get-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $migrationId, Response $response, Database $dbForProject) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - $response->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::get('/v1/migrations/appwrite/report') - ->groups(['api', 'migrations']) - ->desc('Get Appwrite migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getAppwriteReport', - description: '/docs/references/migrations/migration-appwrite-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') - ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") - ->param('projectID', '', new Text(512), "Source's Project ID") - ->param('key', '', new Text(512), "Source's API Key") - ->inject('response') - ->inject('getDatabasesDB') - ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) { - - try { - $appwrite = new Appwrite($projectID, $endpoint, $key, $getDatabasesDB); - $report = $appwrite->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/firebase/report') - ->groups(['api', 'migrations']) - ->desc('Get Firebase migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getFirebaseReport', - description: '/docs/references/migrations/migration-firebase-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') - ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') - ->inject('response') - ->action(function (array $resources, string $serviceAccount, Response $response) { - $serviceAccount = json_decode($serviceAccount, true); - - if (empty($serviceAccount)) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - try { - $firebase = new Firebase($serviceAccount); - $report = $firebase->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/supabase/report') - ->groups(['api', 'migrations']) - ->desc('Get Supabase migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getSupabaseReport', - description: '/docs/references/migrations/migration-supabase-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.') - ->param('apiKey', '', new Text(512), 'Source\'s API Key.') - ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.') - ->param('username', '', new Text(512), 'Source\'s Database Username.') - ->param('password', '', new Text(512), 'Source\'s Database Password.') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) { - try { - $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); - $report = $supabase->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/nhost/report') - ->groups(['api', 'migrations']) - ->desc('Get NHost migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getNHostReport', - description: '/docs/references/migrations/migration-nhost-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.') - ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.') - ->param('region', '', new Text(512), 'Source\'s Region.') - ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.') - ->param('database', '', new Text(512), 'Source\'s Database Name.') - ->param('username', '', new Text(512), 'Source\'s Database Username.') - ->param('password', '', new Text(512), 'Source\'s Database Password.') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) - ->inject('response') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) { - try { - $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); - $report = $nhost->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::patch('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Update retry migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].retry') - ->label('audits.event', 'migration.retry') - ->label('audits.resource', 'migrations/{request.migrationId}') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'retry', - description: '/docs/references/migrations/retry-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('publisherForMigrations') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - if ($migration->getAttribute('status') !== 'failed') { - throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); - } - - $migration - ->setAttribute('status', 'pending') - ->setAttribute('dateUpdated', \time()); - - // Trigger Migration - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response->noContent(); - }); - -Http::delete('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Delete migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].delete') - ->label('audits.event', 'migrationId.delete') - ->label('audits.resource', 'migrations/{request.migrationId}') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'delete', - description: '/docs/references/migrations/delete-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB'); - } - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $response->noContent(); - }); diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 06312d9cb2..88788b73fc 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Migrations; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -39,6 +40,7 @@ class Appwrite extends Platform $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); + $this->addModule(new Migrations\Module()); $this->addModule(new Project\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php new file mode 100644 index 0000000000..006ab3ae90 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php @@ -0,0 +1,110 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/appwrite') + ->desc('Create Appwrite migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createAppwriteMigration', + description: '/docs/references/migrations/migration-appwrite.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') + ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) + ->param('apiKey', '', new Text(512), 'Source API Key') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $projectId, + string $apiKey, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'projectId' => $projectId, + 'apiKey' => $apiKey, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php new file mode 100644 index 0000000000..32d8a62ec3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/appwrite/report') + ->desc('Get Appwrite migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getAppwriteReport', + description: '/docs/references/migrations/migration-appwrite-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") + ->param('projectID', '', new Text(512), "Source's Project ID") + ->param('key', '', new Text(512), "Source's API Key") + ->inject('response') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $projectID, + string $key, + Response $response, + callable $getDatabasesDB + ): void { + try { + $appwrite = new AppwriteSource($projectID, $endpoint, $key, $getDatabasesDB); + $report = $appwrite->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php new file mode 100644 index 0000000000..0ab3cecf1a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php @@ -0,0 +1,213 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/csv/exports') + ->desc('Export documents to CSV') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createCSVExport', + description: '/docs/references/migrations/migration-csv-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true) + ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true) + ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true) + ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $resourceId, + string $filename, + array $columns, + array $queries, + string $delimiter, + string $enclosure, + string $escape, + bool $header, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => CSV::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'delimiter' => $delimiter, + 'enclosure' => $enclosure, + 'escape' => $escape, + 'header' => $header, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php new file mode 100644 index 0000000000..5cc21241c3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php @@ -0,0 +1,220 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/csv/imports') + ->httpAlias('/v1/migrations/csv') + ->desc('Import documents from a CSV') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createCSVImport', + description: '/docs/references/migrations/migration-csv-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject']) + ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + $fileSize = $deviceForMigrations->getFileSize($newPath); + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => CSV::getName(), + 'destination' => AppwriteSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php new file mode 100644 index 0000000000..f9c989b5bf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Delete migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].delete') + ->label('audits.event', 'migrationId.delete') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'delete', + description: '/docs/references/migrations/delete-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action(string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents): void + { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB'); + } + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php new file mode 100644 index 0000000000..a8347858b4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php @@ -0,0 +1,114 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/firebase') + ->desc('Create Firebase migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createFirebaseMigration', + description: '/docs/references/migrations/migration-firebase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $serviceAccount, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $serviceAccountData = json_decode($serviceAccount, true); + + if (empty($serviceAccountData)) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Firebase::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'serviceAccount' => $serviceAccount, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php new file mode 100644 index 0000000000..ef8084795e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/firebase/report') + ->desc('Get Firebase migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getFirebaseReport', + description: '/docs/references/migrations/migration-firebase-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(array $resources, string $serviceAccount, Response $response): void + { + $serviceAccount = json_decode($serviceAccount, true); + + if (empty($serviceAccount)) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + try { + $firebase = new Firebase($serviceAccount); + $report = $firebase->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php new file mode 100644 index 0000000000..14b40e2306 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php @@ -0,0 +1,61 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Get migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.read') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'get', + description: '/docs/references/migrations/get-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $migrationId, Response $response, Database $dbForProject): void + { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + $response->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php new file mode 100644 index 0000000000..d968bd91f6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php @@ -0,0 +1,198 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/json/exports') + ->desc('Export documents to JSON') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONExport', + description: '/docs/references/migrations/migration-json-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $resourceId, + string $filename, + array $columns, + array $queries, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => JSONSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php new file mode 100644 index 0000000000..55081b2645 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php @@ -0,0 +1,221 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/json/imports') + ->desc('Import documents from a JSON') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONImport', + description: '/docs/references/migrations/migration-json-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + $fileSize = $deviceForMigrations->getFileSize($newPath); + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + $databaseType = $database->getAttribute('type'); + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => JSONSource::getName(), + 'destination' => AppwriteSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php new file mode 100644 index 0000000000..fb97b1c16c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php @@ -0,0 +1,122 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/nhost') + ->desc('Create NHost migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createNHostMigration', + description: '/docs/references/migrations/migration-nhost.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') + ->param('subdomain', '', new Text(512), 'Source\'s Subdomain') + ->param('region', '', new Text(512), 'Source\'s Region') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') + ->param('database', '', new Text(512), 'Source\'s Database Name') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $subdomain, + string $region, + string $adminSecret, + string $database, + string $username, + string $password, + int $port, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => NHost::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'subdomain' => $subdomain, + 'region' => $region, + 'adminSecret' => $adminSecret, + 'database' => $database, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php new file mode 100644 index 0000000000..964f2dc347 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/nhost/report') + ->desc('Get NHost migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getNHostReport', + description: '/docs/references/migrations/migration-nhost-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.') + ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.') + ->param('region', '', new Text(512), 'Source\'s Region.') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.') + ->param('database', '', new Text(512), 'Source\'s Database Name.') + ->param('username', '', new Text(512), 'Source\'s Database Username.') + ->param('password', '', new Text(512), 'Source\'s Database Password.') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $subdomain, + string $region, + string $adminSecret, + string $database, + string $username, + string $password, + int $port, + Response $response + ): void { + try { + $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); + $report = $nhost->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php new file mode 100644 index 0000000000..98b33e379d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/supabase') + ->desc('Create Supabase migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createSupabaseMigration', + description: '/docs/references/migrations/migration-supabase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') + ->param('apiKey', '', new Text(512), 'Source\'s API Key') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $apiKey, + string $databaseHost, + string $username, + string $password, + int $port, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Supabase::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'apiKey' => $apiKey, + 'databaseHost' => $databaseHost, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php new file mode 100644 index 0000000000..423e611430 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/supabase/report') + ->desc('Get Supabase migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getSupabaseReport', + description: '/docs/references/migrations/migration-supabase-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.') + ->param('apiKey', '', new Text(512), 'Source\'s API Key.') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.') + ->param('username', '', new Text(512), 'Source\'s Database Username.') + ->param('password', '', new Text(512), 'Source\'s Database Password.') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $apiKey, + string $databaseHost, + string $username, + string $password, + int $port, + Response $response + ): void { + try { + $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); + $report = $supabase->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php new file mode 100644 index 0000000000..8ecc53c2a3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Update retry migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].retry') + ->label('audits.event', 'migration.retry') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'retry', + description: '/docs/references/migrations/retry-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $migrationId, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if ($migration->getAttribute('status') !== 'failed') { + throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); + } + + $migration + ->setAttribute('status', 'pending') + ->setAttribute('dateUpdated', \time()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php new file mode 100644 index 0000000000..1a1252be79 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php @@ -0,0 +1,104 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations') + ->desc('List migrations') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.read') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'list', + description: '/docs/references/migrations/list-migrations.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_LIST, + ) + ] + )) + ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::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): void + { + 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()); + } + + $migrationId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + try { + $migrations = $dbForProject->find('migrations', $queries); + $total = $includeTotal ? $dbForProject->count('migrations', $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([ + 'migrations' => $migrations, + 'total' => $total, + ]), Response::MODEL_MIGRATION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Module.php b/src/Appwrite/Platform/Modules/Migrations/Module.php new file mode 100644 index 0000000000..6ec1e49a88 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Services/Http.php b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php new file mode 100644 index 0000000000..1e2c95a78b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php @@ -0,0 +1,59 @@ +type = Service::TYPE_HTTP; + + // Migrations + $this->addAction(ListMigrations::getName(), new ListMigrations()); + $this->addAction(GetMigration::getName(), new GetMigration()); + $this->addAction(UpdateMigration::getName(), new UpdateMigration()); + $this->addAction(DeleteMigration::getName(), new DeleteMigration()); + + // Appwrite source + $this->addAction(CreateAppwriteMigration::getName(), new CreateAppwriteMigration()); + $this->addAction(GetAppwriteReport::getName(), new GetAppwriteReport()); + + // Firebase source + $this->addAction(CreateFirebaseMigration::getName(), new CreateFirebaseMigration()); + $this->addAction(GetFirebaseReport::getName(), new GetFirebaseReport()); + + // Supabase source + $this->addAction(CreateSupabaseMigration::getName(), new CreateSupabaseMigration()); + $this->addAction(GetSupabaseReport::getName(), new GetSupabaseReport()); + + // NHost source + $this->addAction(CreateNHostMigration::getName(), new CreateNHostMigration()); + $this->addAction(GetNHostReport::getName(), new GetNHostReport()); + + // CSV import / export + $this->addAction(CreateCSVImport::getName(), new CreateCSVImport()); + $this->addAction(CreateCSVExport::getName(), new CreateCSVExport()); + + // JSON import / export + $this->addAction(CreateJSONImport::getName(), new CreateJSONImport()); + $this->addAction(CreateJSONExport::getName(), new CreateJSONExport()); + } +} From 15917ac7ba69bc468079b57803d9d2e54aaa4d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 17:05:30 +0200 Subject: [PATCH 43/56] Fix failing tests --- src/Appwrite/Utopia/Request/Filters/V24.php | 15 +++++++++++++++ tests/e2e/Services/Project/KeysBase.php | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php index 29df762f28..2809c6f2c6 100644 --- a/src/Appwrite/Utopia/Request/Filters/V24.php +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -9,6 +9,21 @@ class V24 extends Filter // Convert 1.9.2 params to 1.9.3 public function parse(array $content, string $model): array { + switch ($model) { + case 'project.createStandardKey': + $content = $this->parseKeyScopes($content); + break; + } + + return $content; + } + + protected function parseKeyScopes(array $content): array + { + if (!\is_array($content['scopes'] ?? null)) { + $content['scopes'] = []; + } + return $content; } } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 5019c8fefd..7ca494fefa 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -256,7 +256,7 @@ trait KeysBase $this->assertNotEmpty($key['body']['secret']); $this->assertStringStartsWith(API_KEY_DYNAMIC . '_', $key['body']['secret']); $this->assertSame([], $key['body']['sdks']); - $this->assertNull($key['body']['accessedAt']); + $this->assertSame('', $key['body']['accessedAt']); $dateValidator = new DatetimeValidator(); $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); From c96836b1c0a1656d60cdfef4c0f0c4e70ab2f1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 17:10:58 +0200 Subject: [PATCH 44/56] Improve code quality of folder decoding project ID --- src/Appwrite/Utopia/Response/Filters/V24.php | 37 ++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php index 8ac5305dc1..29cc2ff4a1 100644 --- a/src/Appwrite/Utopia/Response/Filters/V24.php +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -2,8 +2,11 @@ namespace Appwrite\Utopia\Response\Filters; +use Ahc\Jwt\JWT; +use Ahc\Jwt\JWTException; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filter; +use Utopia\System\System; // Convert 1.9.3 Data format to 1.9.2 format class V24 extends Filter @@ -26,22 +29,28 @@ class V24 extends Filter unset($content['sdks']); unset($content['accessedAt']); - $projectId = ''; - if (isset($content['secret'])) { - $parts = explode('_', $content['secret'], 2); - if (count($parts) === 2) { - $jwtParts = explode('.', $parts[1]); - if (count($jwtParts) >= 2) { - $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $jwtParts[1])), true); - $projectId = $payload['projectId'] ?? ''; - } - } - } - $content['projectId'] = $projectId; - - $content['jwt'] = $content['secret'] ?? ''; + $secret = $content['secret'] ?? ''; unset($content['secret']); + $content['projectId'] = $this->extractProjectId($secret); + $content['jwt'] = $secret; + return $content; } + + private function extractProjectId(string $secret): string + { + $token = explode('_', $secret, 2)[1] ?? ''; + if ($token === '') { + return ''; + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256'); + + try { + return $jwt->decode($token, false)['projectId'] ?? ''; + } catch (JWTException) { + return ''; + } + } } From 980762fc3ed7d2ccb907a8e8c6150debb6377b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 17:18:06 +0200 Subject: [PATCH 45/56] Rename from dynamic key to ephemeral key (api keys) --- app/config/errors.php | 2 +- app/controllers/general.php | 2 +- app/controllers/shared/api.php | 3 +- app/init/constants.php | 2 +- app/init/models.php | 4 +-- src/Appwrite/Auth/Key.php | 8 +++-- .../Functions/Http/Executions/Create.php | 2 +- .../Modules/Functions/Workers/Builds.php | 4 +-- .../Modules/Functions/Workers/Screenshots.php | 2 +- .../Keys/{Dynamic => Ephemeral}/Create.php | 22 ++++++------ .../Http/Project/Keys/Standard/Create.php | 2 +- .../Modules/Project/Services/Http.php | 4 +-- src/Appwrite/Platform/Workers/Functions.php | 2 +- src/Appwrite/Platform/Workers/Migrations.php | 2 +- src/Appwrite/Utopia/Response.php | 2 +- src/Appwrite/Utopia/Response/Filters/V24.php | 4 +-- .../{DynamicKey.php => EphemeralKey.php} | 6 ++-- src/Appwrite/Vcs/Comment.php | 2 +- .../Functions/FunctionsCustomServerTest.php | 4 +-- tests/e2e/Services/Project/KeysBase.php | 36 +++++++++---------- .../Services/Project/KeysIntegrationTest.php | 34 +++++++++--------- .../Services/Sites/SitesCustomServerTest.php | 10 +++--- .../index.js | 0 .../package-lock.json | 4 +-- .../package.json | 2 +- .../setup.sh | 0 tests/unit/Auth/KeyTest.php | 24 ++++++------- 27 files changed, 96 insertions(+), 93 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/Keys/{Dynamic => Ephemeral}/Create.php (81%) rename src/Appwrite/Utopia/Response/Model/{DynamicKey.php => EphemeralKey.php} (77%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/index.js (100%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/package-lock.json (93%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/package.json (89%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/setup.sh (100%) diff --git a/app/config/errors.php b/app/config/errors.php index 07b0cd59ed..fa112bcb6f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -384,7 +384,7 @@ return [ ], Exception::API_KEY_EXPIRED => [ 'name' => Exception::API_KEY_EXPIRED, - 'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.', + 'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.', 'code' => 401, ], diff --git a/app/controllers/general.php b/app/controllers/general.php index 85d5cbedbd..eb4899a3d8 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -399,7 +399,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S 'projectId' => $project->getId(), 'scopes' => $resource->getAttribute('scopes', []) ]); - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-jwt'] = ''; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 7c2f527ccf..c9e4f8b47d 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -183,7 +183,8 @@ Http::init() // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { // Disable authorization checks for project API keys - if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { + // Dynamic supported for backwards compatibility + if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } diff --git a/app/init/constants.php b/app/init/constants.php index c3f67502f2..a4cef6f035 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -255,7 +255,7 @@ const MESSAGE_TYPE_SMS = 'sms'; const MESSAGE_TYPE_PUSH = 'push'; // API key types const API_KEY_STANDARD = 'standard'; -const API_KEY_DYNAMIC = 'dynamic'; +const API_KEY_EPHEMERAL = 'ephemeral'; const API_KEY_ORGANIZATION = 'organization'; const API_KEY_ACCOUNT = 'account'; // Usage metrics diff --git a/app/init/models.php b/app/init/models.php index 699d1561a3..56f24ddc2c 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -70,8 +70,8 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; -use Appwrite\Utopia\Response\Model\DynamicKey; use Appwrite\Utopia\Response\Model\Embedding; +use Appwrite\Utopia\Response\Model\EphemeralKey; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; use Appwrite\Utopia\Response\Model\Execution; @@ -393,7 +393,7 @@ Response::setModel(new Execution()); Response::setModel(new Project()); Response::setModel(new Webhook()); Response::setModel(new Key()); -Response::setModel(new DynamicKey()); +Response::setModel(new EphemeralKey()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 8f645f6f08..0cbaefa4b3 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -105,7 +105,7 @@ class Key /** * Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name. - * Can be a stored API key or a dynamic key (JWT). + * Can be a stored API key or an ephemeral key (JWT). * * @throws Exception */ @@ -138,7 +138,9 @@ class Key ); switch ($type) { - case API_KEY_DYNAMIC: + // Dynamic supported for backwards compatibility + case API_KEY_EPHEMERAL: + case 'dynamic': $jwtObj = new JWT( key: System::getEnv('_APP_OPENSSL_KEY_V1'), algo: 'HS256', @@ -153,7 +155,7 @@ class Key $expired = true; } - $name = $payload['name'] ?? 'Dynamic Key'; + $name = $payload['name'] ?? 'Ephemeral Key'; $projectId = $payload['projectId'] ?? ''; $disabledMetrics = $payload['disabledMetrics'] ?? []; $hostnameOverride = $payload['hostnameOverride'] ?? false; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 5b2f4ff297..4bf2fbc48f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -228,7 +228,7 @@ class Create extends Base $executionId = ID::unique(); $headers['x-appwrite-execution-id'] = $executionId; - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 286f1c55ee..352fb56e28 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -624,7 +624,7 @@ class Builds extends Action $vars = [ ...$vars, 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, - 'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, + 'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey, 'APPWRITE_FUNCTION_ID' => $resource->getId(), 'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), @@ -639,7 +639,7 @@ class Builds extends Action $vars = [ ...$vars, 'APPWRITE_SITE_API_ENDPOINT' => $endpoint, - 'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, + 'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey, 'APPWRITE_SITE_ID' => $resource->getId(), 'APPWRITE_SITE_NAME' => $resource->getAttribute('name'), 'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(), diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index a6f1ca1b03..7d1cdc4980 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -168,7 +168,7 @@ class Screenshots extends Action $config = $configs[$key]; $config['headers'] = \array_merge($config['headers'], [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey ]); $config['sleep'] = 3000; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php similarity index 81% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php index 8839a146fb..cf21eaec74 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/project/keys/dynamic') + ->setHttpPath('/v1/project/keys/ephemeral') ->httpAlias('/v1/projects/:projectId/jwts') - ->desc('Create dynamic project key') + ->desc('Create ephemeral project key') ->groups(['api', 'project']) ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].create') @@ -44,22 +44,22 @@ class Create extends Base ->label('sdk', new Method( namespace: 'project', group: 'keys', - name: 'createDynamicKey', + name: 'createEphemeralKey', description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) - ->param('duration', 900, new Range(1, 3600), 'Time in seconds before dynamic key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->param('duration', 900, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) ->inject('response') ->inject('queueForEvents') ->inject('project') @@ -94,13 +94,13 @@ class Create extends Base 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, - 'secret' => API_KEY_DYNAMIC . '_' . $secret, + 'secret' => API_KEY_EPHEMERAL . '_' . $secret, ]); $queueForEvents->setParam('keyId', $key->getId()); $response ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_DYNAMIC_KEY); + ->dynamic($key, Response::MODEL_EPHEMERAL_KEY); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php index ccf19e4a30..67bdcc09a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php @@ -53,7 +53,7 @@ class Create extends Base description: <<addAction(CreateStandardKey::getName(), new CreateStandardKey()); - $this->addAction(CreateDynamicKey::getName(), new CreateDynamicKey()); + $this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey()); $this->addAction(ListKeys::getName(), new ListKeys()); $this->addAction(GetKey::getName(), new GetKey()); $this->addAction(DeleteKey::getName(), new DeleteKey()); diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 28c298b050..8167fb975d 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -434,7 +434,7 @@ class Functions extends Action ]); $headers['x-appwrite-execution-id'] = $executionId ?? ''; - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId(); diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index fa2ed5883f..69f72b8e27 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -402,7 +402,7 @@ class Migrations extends Action ] ]); - return API_KEY_DYNAMIC . '_' . $apiKey; + return API_KEY_EPHEMERAL . '_' . $apiKey; } /** diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 92eb0768b3..b6c0fcc1ab 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -251,7 +251,7 @@ class Response extends SwooleResponse public const MODEL_WEBHOOK_LIST = 'webhookList'; public const MODEL_KEY = 'key'; public const MODEL_KEY_LIST = 'keyList'; - public const MODEL_DYNAMIC_KEY = 'dynamicKey'; + public const MODEL_EPHEMERAL_KEY = 'ephemeralKey'; public const MODEL_DEV_KEY = 'devKey'; public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php index 29cc2ff4a1..46db062863 100644 --- a/src/Appwrite/Utopia/Response/Filters/V24.php +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -14,12 +14,12 @@ class V24 extends Filter public function parse(array $content, string $model): array { return match ($model) { - Response::MODEL_DYNAMIC_KEY => $this->parseDynamicKey($content), + Response::MODEL_EPHEMERAL_KEY => $this->parseEphemeralKey($content), default => $content, }; } - private function parseDynamicKey(array $content): array + private function parseEphemeralKey(array $content): array { unset($content['$id']); unset($content['$createdAt']); diff --git a/src/Appwrite/Utopia/Response/Model/DynamicKey.php b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php similarity index 77% rename from src/Appwrite/Utopia/Response/Model/DynamicKey.php rename to src/Appwrite/Utopia/Response/Model/EphemeralKey.php index c1016f3fcc..f6b7fdd7f3 100644 --- a/src/Appwrite/Utopia/Response/Model/DynamicKey.php +++ b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php @@ -4,7 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; -class DynamicKey extends Key +class EphemeralKey extends Key { public function __construct() { @@ -18,7 +18,7 @@ class DynamicKey extends Key */ public function getName(): string { - return 'Dynamic Key'; + return 'Ephemeral Key'; } /** @@ -28,6 +28,6 @@ class DynamicKey extends Key */ public function getType(): string { - return Response::MODEL_DYNAMIC_KEY; + return Response::MODEL_EPHEMERAL_KEY; } } diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 6214bb1f29..4dc0174e50 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -31,7 +31,7 @@ class Comment 'Trigger functions via HTTP, SDKs, events, webhooks, or scheduled cron jobs', 'Each function runs in its own isolated container with custom environment variables', 'Build commands execute in runtime containers during deployment', - 'Dynamic API keys are generated automatically for each function execution', + 'Ephemeral API keys are generated automatically for each function execution', 'JWT tokens let functions act on behalf of users while preserving their permissions', 'Storage files get ClamAV malware scanning and encryption by default', 'Roll back Sites deployments instantly by switching between versions', diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 4255774f18..e75c3e5f4e 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -700,7 +700,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(200, $function['headers']['status-code']); $this->assertEquals($deploymentId, $function['body']['deploymentId']); - // Test starter code is used and that dynamic keys work + // Test starter code is used and that ephemeral keys work $execution = $this->createExecution($functionId, [ 'path' => '/ping', ]); @@ -2129,7 +2129,7 @@ class FunctionsCustomServerTest extends Scope ]); $deploymentId = $this->setupDeployment($functionId, [ - 'code' => $this->packageFunction('dynamic-api-key'), + 'code' => $this->packageFunction('ephemeral-api-key'), 'activate' => true, ]); diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 7ca494fefa..cd50f67c14 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -240,12 +240,12 @@ trait KeysBase } // ========================================================================= - // Create dynamic key tests + // Create ephemeral key tests // ========================================================================= - public function testCreateDynamicKey(): void + public function testCreateEphemeralKey(): void { - $key = $this->createDynamicKey( + $key = $this->createEphemeralKey( ['users.read', 'users.write'], ); @@ -254,7 +254,7 @@ trait KeysBase $this->assertSame('', $key['body']['name']); $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); $this->assertNotEmpty($key['body']['secret']); - $this->assertStringStartsWith(API_KEY_DYNAMIC . '_', $key['body']['secret']); + $this->assertStringStartsWith(API_KEY_EPHEMERAL . '_', $key['body']['secret']); $this->assertSame([], $key['body']['sdks']); $this->assertSame('', $key['body']['accessedAt']); @@ -264,7 +264,7 @@ trait KeysBase $this->assertSame(true, $dateValidator->isValid($key['body']['expire'])); // Verify JWT payload - $jwt = substr($key['body']['secret'], strlen(API_KEY_DYNAMIC . '_')); + $jwt = substr($key['body']['secret'], strlen(API_KEY_EPHEMERAL . '_')); $parts = explode('.', $jwt); $this->assertCount(3, $parts); $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); @@ -279,11 +279,11 @@ trait KeysBase $this->assertLessThanOrEqual(910, $diff); } - public function testCreateDynamicKeyWithDuration(): void + public function testCreateEphemeralKeyWithDuration(): void { $duration = 1800; - $key = $this->createDynamicKey( + $key = $this->createEphemeralKey( ['databases.read'], $duration, ); @@ -298,9 +298,9 @@ trait KeysBase $this->assertLessThanOrEqual($duration + 10, $diff); } - public function testCreateDynamicKeyWithEmptyScopes(): void + public function testCreateEphemeralKeyWithEmptyScopes(): void { - $key = $this->createDynamicKey( + $key = $this->createEphemeralKey( [], ); @@ -308,9 +308,9 @@ trait KeysBase $this->assertSame([], $key['body']['scopes']); } - public function testCreateDynamicKeyWithoutAuthentication(): void + public function testCreateEphemeralKeyWithoutAuthentication(): void { - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['users.read'], null, false @@ -319,25 +319,25 @@ trait KeysBase $this->assertSame(401, $response['headers']['status-code']); } - public function testCreateDynamicKeyInvalidScope(): void + public function testCreateEphemeralKeyInvalidScope(): void { - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['invalid.scope'], ); $this->assertSame(400, $response['headers']['status-code']); } - public function testCreateDynamicKeyInvalidDuration(): void + public function testCreateEphemeralKeyInvalidDuration(): void { - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['users.read'], 0, ); $this->assertSame(400, $response['headers']['status-code']); - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['users.read'], 3601, ); @@ -965,7 +965,7 @@ trait KeysBase /** * @param array $scopes */ - protected function createDynamicKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed + protected function createEphemeralKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed { $params = [ 'scopes' => $scopes, @@ -984,6 +984,6 @@ trait KeysBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_POST, '/project/keys/dynamic', $headers, $params); + return $this->client->call(Client::METHOD_POST, '/project/keys/ephemeral', $headers, $params); } } diff --git a/tests/e2e/Services/Project/KeysIntegrationTest.php b/tests/e2e/Services/Project/KeysIntegrationTest.php index 2615cac023..4dc5838e72 100644 --- a/tests/e2e/Services/Project/KeysIntegrationTest.php +++ b/tests/e2e/Services/Project/KeysIntegrationTest.php @@ -13,7 +13,7 @@ class KeysIntegrationTest extends Scope use ProjectCustom; use SideServer; - public function testDynamicKeyScopeEnforcement(): void + public function testEphemeralKeyScopeEnforcement(): void { $projectId = $this->getProject()['$id']; $apiKey = $this->getProject()['apiKey']; @@ -32,25 +32,25 @@ class KeysIntegrationTest extends Scope 'x-appwrite-project' => $projectId, ]; - // Step 1: Create a dynamic key scoped to users.read only. - $dynamicKey = $this->client->call( + // Step 1: Create an ephemeral key scoped to users.read only. + $ephemeralKey = $this->client->call( Client::METHOD_POST, - '/project/keys/dynamic', + '/project/keys/ephemeral', $serverHeaders, [ 'scopes' => ['users.read'], 'duration' => 900, ] ); - $this->assertSame(201, $dynamicKey['headers']['status-code']); - $this->assertNotEmpty($dynamicKey['body']['secret']); + $this->assertSame(201, $ephemeralKey['headers']['status-code']); + $this->assertNotEmpty($ephemeralKey['body']['secret']); - $dynamicKeySecret = $dynamicKey['body']['secret']; + $ephemeralKeySecret = $ephemeralKey['body']['secret']; - $dynamicHeaders = [ + $ephemeralHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $dynamicKeySecret, + 'x-appwrite-key' => $ephemeralKeySecret, ]; // Step 2: Create a project user using console headers. @@ -60,37 +60,37 @@ class KeysIntegrationTest extends Scope $consoleHeaders, [ 'userId' => ID::unique(), - 'email' => 'dynamic_key_' . \uniqid() . '@localhost.test', + 'email' => 'ephemeral_key_' . \uniqid() . '@localhost.test', 'password' => 'password1234', - 'name' => 'Dynamic Key Test User', + 'name' => 'Ephemeral Key Test User', ] ); $this->assertSame(201, $user['headers']['status-code']); $userId = $user['body']['$id']; - // Step 3: Dynamic key can list users. + // Step 3: Ephemeral key can list users. $list = $this->client->call( Client::METHOD_GET, '/users', - $dynamicHeaders + $ephemeralHeaders ); $this->assertSame(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); - // Step 4: Dynamic key can get the specific user. + // Step 4: Ephemeral key can get the specific user. $get = $this->client->call( Client::METHOD_GET, '/users/' . $userId, - $dynamicHeaders + $ephemeralHeaders ); $this->assertSame(200, $get['headers']['status-code']); $this->assertSame($userId, $get['body']['$id']); - // Step 5: Dynamic key cannot create users (missing users.write scope). + // Step 5: Ephemeral key cannot create users (missing users.write scope). $createAttempt = $this->client->call( Client::METHOD_POST, '/users', - $dynamicHeaders, + $ephemeralHeaders, [ 'userId' => ID::unique(), 'email' => 'should_fail_' . \uniqid() . '@localhost.test', diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 71f6675561..42fd190172 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -2038,7 +2038,7 @@ class SitesCustomServerTest extends Scope 'previewAuthDisabled' => true, ]); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertStringContainsString("Hello Appwrite", $response['body']); @@ -2046,7 +2046,7 @@ class SitesCustomServerTest extends Scope $this->assertGreaterThan($contentLength, $response['headers']['content-length']); $response = $proxyClient->call(Client::METHOD_GET, '/non-existing-path', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(404, $response['headers']['status-code']); $this->assertStringContainsString("Page not found", $response['body']); @@ -2882,7 +2882,7 @@ class SitesCustomServerTest extends Scope ]); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $deployment = $this->getDeployment($siteId, $deploymentId); @@ -2924,7 +2924,7 @@ class SitesCustomServerTest extends Scope // deployment is still building error page $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString("Deployment is still building", $response['body']); @@ -2939,7 +2939,7 @@ class SitesCustomServerTest extends Scope // deployment failed error page $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString("Deployment build failed", $response['body']); diff --git a/tests/resources/functions/dynamic-api-key/index.js b/tests/resources/functions/ephemeral-api-key/index.js similarity index 100% rename from tests/resources/functions/dynamic-api-key/index.js rename to tests/resources/functions/ephemeral-api-key/index.js diff --git a/tests/resources/functions/dynamic-api-key/package-lock.json b/tests/resources/functions/ephemeral-api-key/package-lock.json similarity index 93% rename from tests/resources/functions/dynamic-api-key/package-lock.json rename to tests/resources/functions/ephemeral-api-key/package-lock.json index 2d86fe18d3..3756c13c0c 100644 --- a/tests/resources/functions/dynamic-api-key/package-lock.json +++ b/tests/resources/functions/ephemeral-api-key/package-lock.json @@ -1,11 +1,11 @@ { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/tests/resources/functions/dynamic-api-key/package.json b/tests/resources/functions/ephemeral-api-key/package.json similarity index 89% rename from tests/resources/functions/dynamic-api-key/package.json rename to tests/resources/functions/ephemeral-api-key/package.json index 19b8158131..35abec4874 100644 --- a/tests/resources/functions/dynamic-api-key/package.json +++ b/tests/resources/functions/ephemeral-api-key/package.json @@ -1,5 +1,5 @@ { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "main": "index.js", "scripts": { diff --git a/tests/resources/functions/dynamic-api-key/setup.sh b/tests/resources/functions/ephemeral-api-key/setup.sh similarity index 100% rename from tests/resources/functions/dynamic-api-key/setup.sh rename to tests/resources/functions/ephemeral-api-key/setup.sh diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 58fe3113e1..bcdb46180f 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -14,7 +14,7 @@ class KeyTest extends TestCase { public function testDecode(): void { - // Decode dynamic key + // Decode ephemeral key $projectId = 'test'; $usage = false; $scopes = [ @@ -36,12 +36,12 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); - $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals('Ephemeral Key', $decoded->getName()); - // Decode dynamic key with extras + // Decode ephemeral key with extras $extra = [ 'disabledMetrics' => ['metric123'], 'hostnameOverride' => true, @@ -60,10 +60,10 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); - $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals('Ephemeral Key', $decoded->getName()); $this->assertEquals(['metric123'], $decoded->getDisabledMetrics()); $this->assertEquals(true, $decoded->getHostnameOverride()); $this->assertEquals(true, $decoded->isBannerDisabled()); @@ -71,8 +71,8 @@ class KeyTest extends TestCase $this->assertEquals(true, $decoded->isPreviewAuthDisabled()); $this->assertEquals(true, $decoded->isDeploymentStatusIgnored()); - // Decode invalid dynamic key - $invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token'; + // Decode invalid ephemeral key + $invalidKey = API_KEY_EPHEMERAL . '_invalid_jwt_token'; $decoded = Key::decode( project: new Document(['$id' => $projectId]), team: new Document(), @@ -82,12 +82,12 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); $this->assertEquals('UNKNOWN', $decoded->getName()); - // Decode expired dynamic key + // Decode expired ephemeral key $expiredKey = self::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60); \sleep(2); $decoded = Key::decode( @@ -99,7 +99,7 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); $this->assertEquals('UNKNOWN', $decoded->getName()); @@ -363,6 +363,6 @@ class KeyTest extends TestCase 'scopes' => $scopes, ], $extra)); - return API_KEY_DYNAMIC . '_' . $apiKey; + return API_KEY_EPHEMERAL . '_' . $apiKey; } } From 05f2d2b9cf87aadcc249202ddb02d9e4a8ae6639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 19:29:37 +0200 Subject: [PATCH 46/56] Fix tests --- src/Appwrite/Utopia/Request/Filters/V24.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php index 2809c6f2c6..f62c1f8c0b 100644 --- a/src/Appwrite/Utopia/Request/Filters/V24.php +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -11,6 +11,7 @@ class V24 extends Filter { switch ($model) { case 'project.createStandardKey': + $content = $this->fillKeyId($content); $content = $this->parseKeyScopes($content); break; } @@ -18,6 +19,12 @@ class V24 extends Filter return $content; } + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + protected function parseKeyScopes(array $content): array { if (!\is_array($content['scopes'] ?? null)) { From a58ea1123b1b9734a79c7de78cab4dbb34583262 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 13:21:17 +0530 Subject: [PATCH 47/56] chore: bump docker-base to 1.2.0 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7cb007c188..5e9f125de3 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.0.1 AS base +FROM appwrite/base:1.2.0 AS base LABEL maintainer="team@appwrite.io" From 86123c9e93ed198d1cb760ec5bd5c1b35a8a4ba3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 13:27:04 +0530 Subject: [PATCH 48/56] fix: update PHP extension path for xdebug cleanup in production --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5e9f125de3..94747797ff 100755 --- a/Dockerfile +++ b/Dockerfile @@ -100,7 +100,7 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ FROM base AS production RUN rm -rf /usr/src/code/app/config/specs && \ - rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20250925/xdebug.so && \ find /usr -name '*.a' -delete 2>/dev/null || true && \ find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ find /usr -name '*.pyc' -delete 2>/dev/null || true From e75fc5b8598ab03c0cc0829b3f156953e5184fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 10:08:31 +0200 Subject: [PATCH 49/56] Add list scopes endpoint for Console --- app/init/models.php | 4 ++ .../console/list-oauth2-providers.md | 1 - docs/references/console/variables.md | 1 - .../Console/Http/OAuth2Providers/XList.php | 2 +- .../Modules/Console/Http/Scopes/Key/XList.php | 67 +++++++++++++++++++ .../Modules/Console/Http/Variables/Get.php | 2 +- .../Modules/Console/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 2 + .../Utopia/Response/Model/ConsoleKeyScope.php | 37 ++++++++++ .../Response/Model/ConsoleKeyScopeList.php | 37 ++++++++++ .../Console/ConsoleConsoleClientTest.php | 43 ++++++++++++ .../Console/ConsoleCustomServerTest.php | 18 +++++ 12 files changed, 212 insertions(+), 4 deletions(-) delete mode 100644 docs/references/console/list-oauth2-providers.md delete mode 100644 docs/references/console/variables.md create mode 100644 src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php diff --git a/app/init/models.php b/app/init/models.php index 56f24ddc2c..9530b4b98b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -56,6 +56,8 @@ use Appwrite\Utopia\Response\Model\ColumnString; use Appwrite\Utopia\Response\Model\ColumnText; use Appwrite\Utopia\Response\Model\ColumnURL; use Appwrite\Utopia\Response\Model\ColumnVarchar; +use Appwrite\Utopia\Response\Model\ConsoleKeyScope; +use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList; use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider; use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList; use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter; @@ -488,6 +490,8 @@ Response::setModel(new ConsoleVariables()); Response::setModel(new ConsoleOAuth2ProviderParameter()); Response::setModel(new ConsoleOAuth2Provider()); Response::setModel(new ConsoleOAuth2ProviderList()); +Response::setModel(new ConsoleKeyScope()); +Response::setModel(new ConsoleKeyScopeList()); Response::setModel(new MFAChallenge()); Response::setModel(new MFARecoveryCodes()); Response::setModel(new MFAType()); diff --git a/docs/references/console/list-oauth2-providers.md b/docs/references/console/list-oauth2-providers.md deleted file mode 100644 index d813296031..0000000000 --- a/docs/references/console/list-oauth2-providers.md +++ /dev/null @@ -1 +0,0 @@ -List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers. diff --git a/docs/references/console/variables.md b/docs/references/console/variables.md deleted file mode 100644 index ddfa2b9b72..0000000000 --- a/docs/references/console/variables.md +++ /dev/null @@ -1 +0,0 @@ -Get all Environment Variables that are relevant for the console. \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php index 574f7a5f6a..79a36643a1 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -34,7 +34,7 @@ class XList extends Action namespace: 'console', group: 'console', name: 'listOAuth2Providers', - description: '/docs/references/console/list-oauth2-providers.md', + description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.', auth: [AuthType::ADMIN], responses: [ new SDKResponse( diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php new file mode 100644 index 0000000000..255a7583bb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php @@ -0,0 +1,67 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/scopes/key') + ->desc('List key scopes') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listKeyScopes', + description: 'List all scopes available for project API keys, along with a description for each scope.', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $scopesConfig = Config::getParam('projectScopes', []); + + $scopes = []; + foreach ($scopesConfig as $scopeId => $scope) { + $scopes[] = new Document([ + '$id' => $scopeId, + 'description' => $scope['description'] ?? '', + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($scopes), + 'scopes' => $scopes, + ]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php index 8368b272f1..d39049a409 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php @@ -36,7 +36,7 @@ class Get extends Action namespace: 'console', group: 'console', name: 'variables', - description: '/docs/references/console/variables.md', + description: 'Get all Environment Variables that are relevant for the console.', auth: [AuthType::ADMIN], responses: [ new SDKResponse( diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php index 77029af0f9..2540ae8e01 100644 --- a/src/Appwrite/Platform/Modules/Console/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php @@ -15,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister; use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot; use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability; +use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes; use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables; use Utopia\Platform\Service; @@ -30,6 +31,7 @@ class Http extends Service $this->addAction(GetVariables::getName(), new GetVariables()); $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); + $this->addAction(ListKeyScopes::getName(), new ListKeyScopes()); $this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery()); $this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index b6c0fcc1ab..899cdc086a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -335,6 +335,8 @@ class Response extends SwooleResponse public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter'; public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider'; public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList'; + public const MODEL_CONSOLE_KEY_SCOPE = 'consoleKeyScope'; + public const MODEL_CONSOLE_KEY_SCOPE_LIST = 'consoleKeyScopeList'; // Deprecated public const MODEL_PERMISSIONS = 'permissions'; diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php new file mode 100644 index 0000000000..4932707d21 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php @@ -0,0 +1,37 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope ID.', + 'default' => '', + 'example' => 'users.read', + ]) + ->addRule('description', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope description.', + 'default' => '', + 'example' => 'Access to read your project\'s users', + ]) + ; + } + + public function getName(): string + { + return 'Console Key Scope'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_KEY_SCOPE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php new file mode 100644 index 0000000000..aadf3afa63 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php @@ -0,0 +1,37 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of key scopes exposed by the server.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('scopes', [ + 'type' => Response::MODEL_CONSOLE_KEY_SCOPE, + 'description' => 'List of key scopes, each with its ID and description.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console Key Scopes List'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_KEY_SCOPE_LIST; + } +} diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 3b3232cda3..e4566837e9 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -128,4 +128,47 @@ class ConsoleConsoleClientTest extends Scope // Sandbox providers (e.g. paypalSandbox) are included $this->assertContains('paypalSandbox', $providerIds); } + + public function testListKeyScopes(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['scopes']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertEquals($response['body']['total'], \count($response['body']['scopes'])); + + $scopeIds = \array_column($response['body']['scopes'], '$id'); + + // Well-known scopes must be present + $this->assertContains('users.read', $scopeIds); + $this->assertContains('users.write', $scopeIds); + $this->assertContains('functions.read', $scopeIds); + $this->assertContains('functions.write', $scopeIds); + + // Every scope has the expected shape + foreach ($response['body']['scopes'] as $scope) { + $this->assertArrayHasKey('$id', $scope); + $this->assertIsString($scope['$id']); + $this->assertNotEmpty($scope['$id']); + $this->assertArrayHasKey('description', $scope); + $this->assertIsString($scope['description']); + $this->assertNotEmpty($scope['description']); + } + + // A specific scope has the expected description + $usersRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'users.read') { + $usersRead = $scope; + break; + } + } + $this->assertNotNull($usersRead); + $this->assertEquals('Access to read your project\'s users', $usersRead['description']); + } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index d3c64ae039..0c914fade7 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -43,4 +43,22 @@ class ConsoleCustomServerTest extends Scope $this->assertContains('github', $providerIds); $this->assertNotContains('mock', $providerIds); } + + public function testListKeyScopes(): void + { + // Public endpoint: must succeed without admin authentication. Drop the + // headers from getHeaders() and only pass project + content-type. + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['scopes']); + $this->assertGreaterThan(0, $response['body']['total']); + + $scopeIds = \array_column($response['body']['scopes'], '$id'); + $this->assertContains('users.read', $scopeIds); + } } From 9d7df345901314cdbbb5da64b8f2c2d832633903 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 14:29:37 +0530 Subject: [PATCH 50/56] fix: clean up php 8.5 runtime deprecations --- Dockerfile | 8 +- src/Appwrite/Auth/OAuth2.php | 2 - .../Http/Installer/Certificate/Get.php | 1 - .../Modules/Console/Http/Assistant/Create.php | 2 - src/Appwrite/Platform/Workers/Webhooks.php | 80 +++++++++---------- src/Executor/Executor.php | 3 - tests/e2e/Client.php | 2 - .../e2e/Services/Functions/FunctionsBase.php | 1 - .../Services/Migrations/MigrationsBase.php | 1 + tests/e2e/Services/Sites/SitesBase.php | 1 - 10 files changed, 46 insertions(+), 55 deletions(-) diff --git a/Dockerfile b/Dockerfile index 94747797ff..f6852553d7 100755 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,11 @@ ENV _APP_VERSION=$VERSION \ _APP_HOME=https://appwrite.io RUN \ + apk add --update --no-cache git && \ + if [ "$DEBUG" != "true" ]; then \ + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \ + fi && \ if [ "$DEBUG" == "true" ]; then \ apk add boost boost-dev; \ fi @@ -100,7 +105,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ FROM base AS production RUN rm -rf /usr/src/code/app/config/specs && \ - rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20250925/xdebug.so && \ + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \ find /usr -name '*.a' -delete 2>/dev/null || true && \ find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ find /usr -name '*.pyc' -delete 2>/dev/null || true diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index a8a2d175b5..958b28ed18 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -206,8 +206,6 @@ abstract class OAuth2 $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - \curl_close($ch); - if ($code >= 400) { throw new Exception($response, $code); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php index ab0037f4b2..876dc00215 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php @@ -62,7 +62,6 @@ class Get extends Action curl_setopt_array($ch, $options); curl_exec($ch); $errno = curl_errno($ch); - curl_close($ch); return $errno === 0; } diff --git a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php index 554456b041..8953f682d5 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php @@ -85,8 +85,6 @@ class Create extends Action curl_exec($ch); - curl_close($ch); - $response->chunk('', true); } } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 5b0497dbea..a7f4595966 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -106,51 +106,47 @@ class Webhooks extends Action $httpPass = $webhook->getAttribute('httpPass'); $ch = \curl_init($webhook->getAttribute('url')); - try { - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - \curl_setopt($ch, CURLOPT_HEADER, 0); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_TIMEOUT, 15); - \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); - \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( - APP_USERAGENT, - System::getEnv('_APP_VERSION', 'UNKNOWN'), - System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) - )); - \curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - [ - 'Content-Type: application/json', - 'Content-Length: ' . \strlen($payload), - 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), - 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), - 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), - 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), - 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), - 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, - ] - ); - \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); + \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + \curl_setopt($ch, CURLOPT_HEADER, 0); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, CURLOPT_TIMEOUT, 15); + \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); + \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( + APP_USERAGENT, + System::getEnv('_APP_VERSION', 'UNKNOWN'), + System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) + )); + \curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + [ + 'Content-Type: application/json', + 'Content-Length: ' . \strlen($payload), + 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), + 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), + 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), + 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), + 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), + 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, + ] + ); + \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); - if (!$webhook->getAttribute('security', true)) { - \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } - - if (!empty($httpUser) && !empty($httpPass)) { - \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); - \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - } - - $responseBody = \curl_exec($ch); - $curlError = \curl_error($ch); - $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - } finally { - \curl_close($ch); + if (!$webhook->getAttribute('security', true)) { + \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } + if (!empty($httpUser) && !empty($httpPass)) { + \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); + \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + + $responseBody = \curl_exec($ch); + $curlError = \curl_error($ch); + $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + if (!empty($curlError) || $statusCode >= 400) { $dbForPlatform->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1); $webhook = $dbForPlatform->getDocument('webhooks', $webhook->getId()); diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index a4f1ae44cd..eb74867c9c 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -378,7 +378,6 @@ class Executor $responseBody = curl_exec($ch); if (isset($callback)) { - curl_close($ch); return []; } @@ -418,8 +417,6 @@ class Executor throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus); } - curl_close($ch); - $responseHeaders['status-code'] = $responseStatus; return [ diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 4358058fe3..6965a87d73 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -294,8 +294,6 @@ class Client throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); } - curl_close($ch); - $responseHeaders['status-code'] = $responseStatus; if ($responseStatus === 500) { diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 42976cda84..458359bbe9 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -352,7 +352,6 @@ trait FunctionsBase $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); if ($httpCode === 200) { $commitData = json_decode($response, true); diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 069dc9cfbb..4346e5a5fa 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1303,6 +1303,7 @@ trait MigrationsBase $mimeType = match ($csvFileName) { default => 'text/csv', + 'missing-column.csv', 'missing-row.csv' => 'text/plain', // invalid csv structure, falls back to plain text! }; diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index c3377faad8..7b9c5e86b0 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -350,7 +350,6 @@ trait SitesBase $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); if ($httpCode === 200) { $commitData = json_decode($response, true); From ec3aa2b54f0fc14365051ce6c203f6a11d54f533 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:09:39 +0530 Subject: [PATCH 51/56] ci: share docker image via GHCR instead of upload-artifact The build job uploads the appwrite-dev image as an actions artifact (~hundreds of MB), and 30+ E2E test jobs all pull it concurrently with actions/download-artifact. GitHub Actions' artifact storage struggles with that many parallel downloads and intermittently fails with BlobNotFound or 'Artifact download failed after 5 retries'. Push the built image to ghcr.io//appwrite-dev: in the build job and pull from GHCR in each test job. GHCR handles parallel image fetches without throttling. Mirrors appwrite-labs/cloud#3906. --- .github/workflows/ci.yml | 150 ++++++++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e521ac3771..8cc3b3e113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ concurrency: env: COMPOSE_FILE: docker-compose.yml IMAGE: appwrite-dev + REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev K6_VERSION: '0.53.0' on: @@ -19,6 +20,10 @@ on: type: string default: '' +permissions: + contents: read + packages: write + jobs: dependencies: name: Checks / Dependencies @@ -258,32 +263,30 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Build Appwrite + - name: Build and push Appwrite uses: docker/build-push-action@v6 with: context: . - push: false - tags: ${{ env.IMAGE }} - load: true + push: true + tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar target: development build-args: | DEBUG=false TESTING=true VERSION=dev - - name: Upload Docker Image - uses: actions/upload-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp/${{ env.IMAGE }}.tar - retention-days: 1 - unit: name: Tests / Unit runs-on: ubuntu-latest @@ -291,26 +294,32 @@ jobs: permissions: contents: read pull-requests: write + packages: read steps: - name: checkout uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -338,26 +347,32 @@ jobs: permissions: contents: read pull-requests: write + packages: read steps: - name: checkout uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -396,6 +411,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -450,16 +466,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Set environment run: | echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV - + if [ "${{ matrix.database }}" = "MariaDB" ]; then echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV @@ -483,6 +493,18 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -491,7 +513,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -545,6 +566,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -555,18 +577,24 @@ jobs: with: fetch-depth: 1 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -575,7 +603,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -606,6 +633,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -614,18 +642,24 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -633,7 +667,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -675,28 +708,31 @@ jobs: contents: read issues: write pull-requests: write + packages: read steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 1 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Load Appwrite image + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Appwrite image run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after - name: Setup k6 uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 From 701f557755046e934ef2082f3e39179c02deb8fc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:23:05 +0530 Subject: [PATCH 52/56] ci: clean up GHCR CI image after pipeline finishes Every CI run pushes ghcr.io//appwrite-dev: and nothing removes it. On an active repo with many PRs the GHCR storage grows without bound. Add a cleanup job that runs after all consumer jobs complete (always, even if some fail) and deletes the SHA-tagged package version via the Packages API. Addresses Greptile feedback on appwrite/appwrite#12176. --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc3b3e113..3c644dbec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -871,3 +871,29 @@ jobs: - name: Fail benchmark if: always() && steps.benchmark_after.outcome != 'success' run: exit 1 + + cleanup: + name: Cleanup GHCR Image + if: ${{ always() && github.event_name == 'pull_request' }} + needs: [build, unit, e2e_general, e2e_service, e2e_abuse, e2e_screenshots, benchmark] + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Delete CI image from GHCR + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" + encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" + version_id=$(gh api -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ + --jq ".[] | select(.metadata.container.tags | index(\"${GITHUB_SHA}\")) | .id") + if [ -n "$version_id" ]; then + gh api --method DELETE -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" + echo "Deleted ${package_path}:${GITHUB_SHA} (version ${version_id})" + else + echo "No GHCR version found for SHA ${GITHUB_SHA}" + fi From 444739685962fcabc0bc596d87f42699aa25630e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:37:59 +0530 Subject: [PATCH 53/56] Update base image to 1.2.1 --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f6852553d7..1922a0d2b9 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.2.0 AS base +FROM appwrite/base:1.2.1 AS base LABEL maintainer="team@appwrite.io" @@ -24,7 +24,6 @@ ENV _APP_VERSION=$VERSION \ _APP_HOME=https://appwrite.io RUN \ - apk add --update --no-cache git && \ if [ "$DEBUG" != "true" ]; then \ rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \ From d13e6d75f0bddabd28b73e4456bd476e2e2441e9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:58:59 +0530 Subject: [PATCH 54/56] Fix Trivy SARIF categories on nightly scan --- .github/workflows/nightly.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5cbec8f867..2b56a7e1d6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,9 +24,10 @@ jobs: ignore-unfixed: 'false' severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-image-results.sarif' + category: 'trivy-image' scan-code: name: Scan Code @@ -42,6 +43,7 @@ jobs: output: 'trivy-fs-results.sarif' severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-fs-results.sarif' + category: 'trivy-source' From 360d08f0873aebdc4d1b9db7e8d22f6be8bbcf4c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 16:01:15 +0530 Subject: [PATCH 55/56] Preserve CI image for job retries --- .github/workflows/ci.yml | 26 ----------------------- .github/workflows/cleanup-cache.yml | 32 ++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c644dbec5..8cc3b3e113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -871,29 +871,3 @@ jobs: - name: Fail benchmark if: always() && steps.benchmark_after.outcome != 'success' run: exit 1 - - cleanup: - name: Cleanup GHCR Image - if: ${{ always() && github.event_name == 'pull_request' }} - needs: [build, unit, e2e_general, e2e_service, e2e_abuse, e2e_screenshots, benchmark] - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Delete CI image from GHCR - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" - encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" - version_id=$(gh api -H "Accept: application/vnd.github+json" \ - "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ - --jq ".[] | select(.metadata.container.tags | index(\"${GITHUB_SHA}\")) | .id") - if [ -n "$version_id" ]; then - gh api --method DELETE -H "Accept: application/vnd.github+json" \ - "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" - echo "Deleted ${package_path}:${GITHUB_SHA} (version ${version_id})" - else - echo "No GHCR version found for SHA ${GITHUB_SHA}" - fi diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml index 8f9f05a38c..4b6b13d35d 100644 --- a/.github/workflows/cleanup-cache.yml +++ b/.github/workflows/cleanup-cache.yml @@ -5,6 +5,11 @@ on: types: - closed +permissions: + actions: write + contents: read + packages: write + jobs: cleanup: runs-on: ubuntu-latest @@ -36,4 +41,29 @@ jobs: done done env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cleanup GHCR image + continue-on-error: true + run: | + package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" + encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" + + gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits" --jq '.[].sha' | while read -r sha; do + version_ids=$(gh api --paginate -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ + --jq ".[] | select(.metadata.container.tags | index(\"${sha}\")) | .id") + + if [ -z "$version_ids" ]; then + echo "No GHCR version found for SHA ${sha}" + continue + fi + + echo "$version_ids" | while read -r version_id; do + gh api --method DELETE -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" + echo "Deleted ${package_path}:${sha} (version ${version_id})" + done + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From cccafeff0c640f0a07fb3e2d26c925a9e160b42f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 16:02:41 +0530 Subject: [PATCH 56/56] Add nightly SARIF upload guards --- .github/workflows/nightly.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2b56a7e1d6..c4289678bb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,6 +25,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' category: 'trivy-image' @@ -44,6 +45,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' category: 'trivy-source'