diff --git a/.claude/skills/patch-release-checklist/SKILL.md b/.claude/skills/patch-release-checklist/SKILL.md new file mode 100644 index 0000000000..000fcd065a --- /dev/null +++ b/.claude/skills/patch-release-checklist/SKILL.md @@ -0,0 +1,29 @@ +# Patch Release Checklist for Appwrite + +When bumping a patch version (e.g., `1.9.0` -> `1.9.1`), follow this checklist. + +## Checklist + +### Bump console image + +Update the console Docker image tag in both files: +- [ ] `docker-compose.yml` -- update `image: appwrite/console:X.Y.Z` +- [ ] `app/views/install/compose.phtml` -- update `image: /console:X.Y.Z` + +### Bump Appwrite version + +- [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1. +- [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell). +- [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks. +- [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version + +### Update CHANGES.md + +- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md` with subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous` + +## Final review + +- [ ] Ask user to review changes before commiting +- [ ] Ask user to update `CHANGES.md` with PRs +- [ ] Ask user to generate specs, if needed +- [ ] Ask user to add request and response filters, if needed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aeae21655..d8256ddc7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,7 +512,7 @@ jobs: # Services that rely on sequential test method execution (shared static state) FUNCTIONAL_FLAG="--functional" case "${{ matrix.service }}" in - Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac docker compose exec -T \ diff --git a/AGENTS.md b/AGENTS.md index 4d11ff0ee3..4c5db871d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,10 @@ Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, ` - Never hardcode credentials -- use environment variables. - Code changes may require container restart. No central log location -- check relevant containers. +## Patch release process + +For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid. + ## Cross-repo context Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console. diff --git a/README-CN.md b/README-CN.md index 212b5bb08d..2c7402f1ef 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 88d527f060..31076ffa31 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/cli.php b/app/cli.php index 458df2d642..a6267fa341 100644 --- a/app/cli.php +++ b/app/cli.php @@ -2,12 +2,12 @@ require_once __DIR__ . '/init.php'; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Publisher\Certificate as CertificatePublisher; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Event\StatsResources; use Appwrite\Platform\Appwrite; use Appwrite\Runtimes\Runtimes; use Appwrite\Usage\Context as UsageContext; @@ -253,18 +253,20 @@ $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePubli $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); -$container->set('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); -}, ['publisher']); +$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher( + $publisher, + new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) +), ['publisher']); $container->set('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); -$container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); -}, ['publisher']); $container->set('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { Console::error('[Error] Timestamp: ' . date('c', time())); diff --git a/app/config/apis.php b/app/config/protocols.php similarity index 69% rename from app/config/apis.php rename to app/config/protocols.php index a625999682..bb12453712 100644 --- a/app/config/apis.php +++ b/app/config/protocols.php @@ -9,8 +9,8 @@ return [ 'key' => 'graphql', 'name' => 'GraphQL', ], - 'realtime' => [ - 'key' => 'realtime', - 'name' => 'Realtime', + 'websocket' => [ + 'key' => 'websocket', + 'name' => 'Websocket', ], ]; diff --git a/app/config/services.php b/app/config/services.php index a99501c530..548f659a81 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -137,7 +137,7 @@ return [ 'docs' => true, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -193,7 +193,7 @@ return [ 'docs' => false, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -235,7 +235,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/proxy', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/proxy.png', 'platforms' => ['client', 'server', 'console'], ], @@ -291,7 +291,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', 'tests' => true, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/migrations.png', 'platforms' => ['client', 'server', 'console'], ], diff --git a/app/config/variables.php b/app/config/variables.php index 7a3ed13049..c834656ff4 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -872,18 +872,18 @@ return [ ], [ 'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT', - 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.', + 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.', 'introduction' => '0.13.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' ], [ 'name' => '_APP_COMPUTE_BUILD_TIMEOUT', - 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.', + 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.', 'introduction' => '1.7.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 67588ffd5d..ba2ef87d3a 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -9,6 +9,7 @@ use Appwrite\Auth\Validator\PasswordDictionary; use Appwrite\Auth\Validator\PasswordHistory; use Appwrite\Auth\Validator\PersonalData; use Appwrite\Auth\Validator\Phone; +use Appwrite\Bus\Events\SessionCreated; use Appwrite\Detector\Detector; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -41,6 +42,7 @@ use Utopia\Auth\Proofs\Code as ProofsCode; use Utopia\Auth\Proofs\Password as ProofsPassword; use Utopia\Auth\Proofs\Token as ProofsToken; use Utopia\Auth\Store; +use Utopia\Bus\Bus; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -75,139 +77,8 @@ use Utopia\Validator\WhiteList; $oauthDefaultSuccess = '/console/auth/oauth2/success'; $oauthDefaultFailure = '/console/auth/oauth2/failure'; -function sendSessionAlert(Locale $locale, Document $user, Document $project, array $platform, Document $session, Mail $queueForMails) -{ - $subject = $locale->getText("emails.sessionAlert.subject"); - $preview = $locale->getText("emails.sessionAlert.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? []; - $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); - $validator = new FileName(); - if (!$validator->isValid($smtpBaseTemplate)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path'); - } - - $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; - - $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl'); - $message - ->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello")) - ->setParam('{{body}}', $locale->getText("emails.sessionAlert.body")) - ->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice")) - ->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress")) - ->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry")) - ->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer")) - ->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks")) - ->setParam('{{signature}}', $locale->getText("emails.sessionAlert.signature")); - - $body = $message->render(); - - $smtp = $project->getAttribute('smtp', []); - $smtpEnabled = $smtp['enabled'] ?? false; - - $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); - $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; - - if ($smtpEnabled) { - if (!empty($smtp['senderEmail'])) { - $senderEmail = $smtp['senderEmail']; - } - if (!empty($smtp['senderName'])) { - $senderName = $smtp['senderName']; - } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; - } - - $queueForMails - ->setSmtpHost($smtp['host'] ?? '') - ->setSmtpPort($smtp['port'] ?? '') - ->setSmtpUsername($smtp['username'] ?? '') - ->setSmtpPassword($smtp['password'] ?? '') - ->setSmtpSecure($smtp['secure'] ?? ''); - - if (!empty($customTemplate)) { - if (!empty($customTemplate['senderEmail'])) { - $senderEmail = $customTemplate['senderEmail']; - } - if (!empty($customTemplate['senderName'])) { - $senderName = $customTemplate['senderName']; - } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; - } - - $body = $customTemplate['message'] ?? ''; - $subject = $customTemplate['subject'] ?? $subject; - } - - $queueForMails - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName); - } - - // session alerts should always have a client name! - $clientName = $session->getAttribute('clientName'); - if (empty($clientName)) { - // fallback to the user agent and then unknown! - $userAgent = $session->getAttribute('userAgent'); - $clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN'; - - $session->setAttribute('clientName', $clientName); - } - - $projectName = $project->getAttribute('name'); - if ($project->getId() === 'console') { - $projectName = $platform['platformName']; - } - - $emailVariables = [ - 'direction' => $locale->getText('settings.direction'), - 'date' => (new \DateTime())->format('F j'), - 'year' => (new \DateTime())->format('YYYY'), - 'time' => (new \DateTime())->format('H:i:s'), - 'user' => $user->getAttribute('name'), - 'project' => $projectName, - 'device' => $session->getAttribute('clientName'), - 'ipAddress' => $session->getAttribute('ip'), - 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')), - ]; - - if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { - $emailVariables = array_merge($emailVariables, [ - 'accentColor' => $platform['accentColor'], - 'logoUrl' => $platform['logoUrl'], - 'twitter' => $platform['twitterUrl'], - 'discord' => $platform['discordUrl'], - 'github' => $platform['githubUrl'], - 'terms' => $platform['termsUrl'], - 'privacy' => $platform['privacyUrl'], - 'platform' => $platform['platformName'], - ]); - } - - $email = $user->getAttribute('email'); - - $queueForMails - ->setSubject($subject) - ->setPreview($preview) - ->setBody($body) - ->setBodyTemplate($bodyTemplate) - ->appendVariables($emailVariables) - ->setRecipient($email); - - // since this is console project, set email sender name! - if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { - $queueForMails->setSenderName($platform['emailSenderName']); - } - - $queueForMails->trigger(); -} - - -$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { +$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) $oauthProvider = null; @@ -318,23 +189,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); } - $isAllowedTokenType = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_MAGIC_URL, - TOKEN_TYPE_EMAIL => false, - default => true - }; - - $hasUserEmail = $user->getAttribute('email', false) !== false; - - $isSessionAlertsEnabled = $project->getAttribute('auths', [])['sessionAlerts'] ?? false; - - $isNotFirstSession = $dbForProject->count('sessions', [ - Query::equal('userId', [$user->getId()]), - ]) !== 1; - - if ($isAllowedTokenType && $hasUserEmail && $isSessionAlertsEnabled && $isNotFirstSession) { - sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails); - } + $bus->dispatch(new SessionCreated( + user: $user->getArrayCopy(), + project: $project->getArrayCopy(), + session: $session->getArrayCopy(), + locale: $locale->default, + )); $queueForEvents ->setParam('userId', $user->getId()) @@ -614,19 +474,26 @@ Http::delete('/v1/account') ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForDeletes') - ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) { + ->inject('authorization') + ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) { if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } if ($project->getId() === 'console') { - // get all memberships $memberships = $user->getAttribute('memberships', []); foreach ($memberships as $membership) { - // prevent deletion if at least one active membership - if ($membership->getAttribute('confirm', false)) { - throw new Exception(Exception::USER_DELETION_PROHIBITED); + if (!$membership->getAttribute('confirm', false)) { + continue; } + + $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId')); + if ($team->isEmpty()) { + continue; + } + + // Team is left as-is — we don't promote non-owner members to owner. + // Orphan teams are cleaned up later by Cloud's inactive project cleanup. } } @@ -1034,7 +901,7 @@ Http::post('/v1/account/sessions/email') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('hooks') ->inject('store') ->inject('proofForPassword') @@ -1042,7 +909,7 @@ Http::post('/v1/account/sessions/email') ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { + ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -1141,15 +1008,12 @@ Http::post('/v1/account/sessions/email') ->setParam('sessionId', $session->getId()) ; - if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) { - if ( - $dbForProject->count('sessions', [ - Query::equal('userId', [$user->getId()]), - ]) !== 1 - ) { - sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails); - } - } + $bus->dispatch(new SessionCreated( + user: $user->getArrayCopy(), + project: $project->getArrayCopy(), + session: $session->getArrayCopy(), + locale: $locale->default, + )); $response->dynamic($session, Response::MODEL_SESSION); }); @@ -1343,7 +1207,7 @@ Http::post('/v1/account/sessions/token') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') @@ -2897,16 +2761,16 @@ Http::put('/v1/account/sessions/magic-url') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('store') ->inject('proofForCode') ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) { + ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) { $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); $proofForToken->setHash(new Sha()); - $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization); + $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization); }); Http::put('/v1/account/sessions/phone') @@ -2948,7 +2812,7 @@ Http::put('/v1/account/sessions/phone') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 937380b643..9ec2479749 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -231,7 +231,7 @@ function execute( $validations = GraphQL::getStandardValidationRules(); if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') { - $validations[] = new DisableIntrospection(); + $validations[] = new DisableIntrospection(DisableIntrospection::ENABLED); } if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') { diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 45a663fb56..4c541d2817 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -1,7 +1,8 @@ inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->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', @@ -114,12 +114,11 @@ Http::post('/v1/migrations/appwrite') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -151,10 +150,9 @@ Http::post('/v1/migrations/firebase') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->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)) { @@ -183,12 +181,11 @@ Http::post('/v1/migrations/firebase') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -225,10 +222,9 @@ Http::post('/v1/migrations/supabase') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->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', @@ -252,12 +248,11 @@ Http::post('/v1/migrations/supabase') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -295,10 +290,9 @@ Http::post('/v1/migrations/nhost') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->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, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->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', @@ -323,12 +317,11 @@ Http::post('/v1/migrations/nhost') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -368,7 +361,7 @@ Http::post('/v1/migrations/csv/imports') ->inject('deviceForFiles') ->inject('deviceForMigrations') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $bucketId, string $fileId, @@ -383,7 +376,7 @@ Http::post('/v1/migrations/csv/imports') Device $deviceForFiles, Device $deviceForMigrations, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { if ($internalFile) { @@ -479,11 +472,10 @@ Http::post('/v1/migrations/csv/imports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setProject($project) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -526,7 +518,7 @@ Http::post('/v1/migrations/csv/exports') ->inject('project') ->inject('platform') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $resourceId, string $filename, @@ -545,7 +537,7 @@ Http::post('/v1/migrations/csv/exports') Document $project, array $platform, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { try { $parsedQueries = Query::parseQueries($queries); @@ -630,11 +622,11 @@ Http::post('/v1/migrations/csv/exports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -673,7 +665,7 @@ Http::post('/v1/migrations/json/imports') ->inject('deviceForFiles') ->inject('deviceForMigrations') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $bucketId, string $fileId, @@ -688,7 +680,7 @@ Http::post('/v1/migrations/json/imports') Device $deviceForFiles, Device $deviceForMigrations, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { if ($internalFile) { @@ -783,11 +775,11 @@ Http::post('/v1/migrations/json/imports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -826,7 +818,7 @@ Http::post('/v1/migrations/json/exports') ->inject('project') ->inject('platform') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $resourceId, string $filename, @@ -841,7 +833,7 @@ Http::post('/v1/migrations/json/exports') Document $project, array $platform, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { try { $parsedQueries = Query::parseQueries($queries); @@ -915,11 +907,11 @@ Http::post('/v1/migrations/json/exports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -1216,9 +1208,8 @@ Http::patch('/v1/migrations/:migrationId') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') - ->inject('queueForMigrations') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Migration $queueForMigrations) { + ->inject('publisherForMigrations') + ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->getDocument('migrations', $migrationId); if ($migration->isEmpty()) { @@ -1234,12 +1225,11 @@ Http::patch('/v1/migrations/:migrationId') ->setAttribute('dateUpdated', \time()); // Trigger Migration - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response->noContent(); }); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dac6ed456a..5b82e6c1a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -71,202 +71,20 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service') - ->desc('Update service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatus', - description: '/docs/references/projects/update-service-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('service', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name.') - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $service, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $services = $project->getAttribute('services', []); - $services[$service] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/service/all') ->desc('Update all service status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatusAll', - description: '/docs/references/projects/update-service-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allServices = array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])); - - $services = []; - foreach ($allServices as $service) { - $services[$service] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/api') - ->desc('Update API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatus', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('api', '', new WhiteList(array_keys(Config::getParam('apis')), true), 'API name.') - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $api, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $apis = $project->getAttribute('apis', []); - $apis[$api] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/api/all') ->desc('Update all API status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatusAll', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allApis = array_keys(Config::getParam('apis')); - - $apis = []; - foreach ($allApis as $api) { - $apis[$api] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/oauth2') diff --git a/app/controllers/general.php b/app/controllers/general.php index c6e2eacb33..b4f4a5c1d1 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -7,9 +7,9 @@ use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Bus\Events\ExecutionCompleted; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Cors; use Appwrite\Platform\Appwrite; @@ -25,6 +25,7 @@ use Appwrite\Utopia\Request\Filters\V18 as RequestV18; use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; +use Appwrite\Utopia\Request\Filters\V22 as RequestV22; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -32,6 +33,7 @@ use Appwrite\Utopia\Response\Filters\V18 as ResponseV18; use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; +use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -892,6 +894,9 @@ Http::init() if (version_compare($requestFormat, '1.9.0', '<')) { $request->addFilter(new RequestV21()); } + if (version_compare($requestFormat, '1.9.1', '<')) { + $request->addFilter(new RequestV22()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -916,6 +921,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.1', '<')) { + $response->addFilter(new ResponseV22()); + } if (version_compare($responseFormat, '1.9.0', '<')) { $response->addFilter(new ResponseV21()); } @@ -1006,11 +1014,11 @@ Http::init() ->inject('request') ->inject('console') ->inject('dbForPlatform') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('platform') ->inject('authorization') ->inject('certifiedDomains') - ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) { + ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) { $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; @@ -1036,7 +1044,7 @@ Http::init() } // 4. Check/create rule (requires DB access) - $authorization->skip(function () use ($dbForPlatform, $domain, $console, $queueForCertificates, $certifiedDomains) { + $authorization->skip(function () use ($dbForPlatform, $domain, $console, $publisherForCertificates, $certifiedDomains) { try { // TODO: (@Meldiron) Remove after 1.7.x migration $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; @@ -1092,10 +1100,11 @@ Http::init() $dbForPlatform->createDocument('rules', $document); Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); - $queueForCertificates - ->setDomain($document) - ->setSkipRenewCheck(true) - ->trigger(); + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $console, + domain: $document, + skipRenewCheck: true, + )); } catch (Duplicate $e) { Console::info('Certificate already exists'); } finally { @@ -1168,15 +1177,6 @@ Http::error() ->inject('devKey') ->inject('authorization') ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { - $trace = $error->getTrace(); - - foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { - $file = isset($traceEntry['file']) ? $traceEntry['file'] : '[internal function]'; - $line = isset($traceEntry['line']) ? $traceEntry['line'] : ''; - $function = isset($traceEntry['function']) ? $traceEntry['function'] : ''; - Console::error("[$index] $file : $line -> $function()"); - } - $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); $route = $utopia->getRoute(); $class = \get_class($error); @@ -1186,9 +1186,7 @@ Http::error() $line = $error->getLine(); $trace = $error->getTrace(); - if (php_sapi_name() === 'cli') { - Span::error($error); - } + Span::error($error); switch ($class) { case Utopia\Http\Exception::class: @@ -1430,6 +1428,7 @@ Http::error() case 402: // Error allowed publicly case 403: // Error allowed publicly case 404: // Error allowed publicly + case 405: // Error allowed publicly case 408: // Error allowed publicly case 409: // Error allowed publicly case 412: // Error allowed publicly diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 11ac345fca..5567281e67 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,15 +3,17 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Audit; use Appwrite\Event\Build; +use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Audit as AuditMessage; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; +use Appwrite\Event\Publisher\Audit; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; @@ -37,6 +39,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\Roles; use Utopia\Http\Http; +use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Validator\WhiteList; @@ -87,7 +90,7 @@ Http::init() ->inject('request') ->inject('dbForPlatform') ->inject('dbForProject') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('project') ->inject('user') ->inject('session') @@ -96,7 +99,7 @@ Http::init() ->inject('team') ->inject('apiKey') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { $route = $utopia->getRoute(); if ($route === null) { throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); @@ -192,7 +195,7 @@ Http::init() 'name' => $apiKey->getName(), ]); - $queueForAudits->setUser($user); + $auditContext->user = $user; } // For standard keys, update last accessed time @@ -263,7 +266,7 @@ Http::init() API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, default => ACTIVITY_TYPE_KEY_PROJECT, }); - $queueForAudits->setUser($userClone); + $auditContext->user = $userClone; } // Apply permission @@ -424,7 +427,7 @@ Http::init() } if (! empty($method)) { - $namespace = $method->getNamespace(); + $namespace = \strtolower($method->getNamespace()); if ( array_key_exists($namespace, $project->getAttribute('services', [])) @@ -435,6 +438,15 @@ Http::init() } } + // Step 8b: Check REST protocol status + if ( + array_key_exists('rest', $project->getAttribute('apis', [])) + && ! $project->getAttribute('apis', [])['rest'] + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + ) { + throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); + } + // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { @@ -476,7 +488,7 @@ Http::init() ->inject('user') ->inject('queueForEvents') ->inject('queueForMessaging') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('queueForDeletes') ->inject('queueForDatabase') ->inject('queueForBuilds') @@ -493,7 +505,7 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { $response->setUser($user); $request->setUser($user); @@ -510,14 +522,6 @@ Http::init() default => '', }; - if ( - array_key_exists('rest', $project->getAttribute('apis', [])) - && ! $project->getAttribute('apis', [])['rest'] - && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) - ) { - throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); - } - /* * Abuse Check */ @@ -594,13 +598,12 @@ Http::init() ->setProject($project) ->setUser($user); - $queueForAudits - ->setMode($mode) - ->setUserAgent($request->getUserAgent('')) - ->setIP($request->getIP()) - ->setHostname($request->getHostname()) - ->setEvent($route->getLabel('audits.event', '')) - ->setProject($project); + $auditContext->mode = $mode; + $auditContext->userAgent = $request->getUserAgent(''); + $auditContext->ip = $request->getIP(); + $auditContext->hostname = $request->getHostname(); + $auditContext->event = $route->getLabel('audits.event', ''); + $auditContext->project = $project; /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { @@ -609,7 +612,7 @@ Http::init() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $queueForAudits->setUser($userClone); + $auditContext->user = $userClone; } /* Auto-set projects */ @@ -633,6 +636,7 @@ Http::init() $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles()); $key = $request->cacheIdentifier(); + Span::add('storage.cache.key', $key); $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) @@ -681,6 +685,8 @@ Http::init() if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } + Span::add('storage.bucket.id', $bucketId); + Span::add('storage.file.id', $fileId); // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); @@ -693,16 +699,27 @@ Http::init() } } + $accessedAt = $cacheLog->getAttribute('accessedAt', ''); + if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { + $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ + 'accessedAt' => DateTime::now(), + ]))); + // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid + $cache->save($key, $data); + } + $response ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp)) ->addHeader('X-Appwrite-Cache', 'hit') ->setContentType($cacheLog->getAttribute('mimeType')); $storageCacheOperationsCounter->add(1, ['result' => 'hit']); if (! $isImageTransformation || ! $isDisabled) { + Span::add('storage.cache.hit', true); $response->send($data); } } else { $storageCacheOperationsCounter->add(1, ['result' => 'miss']); + Span::add('storage.cache.hit', false); $response ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') ->addHeader('Pragma', 'no-cache') @@ -774,7 +791,8 @@ Http::shutdown() ->inject('project') ->inject('user') ->inject('queueForEvents') - ->inject('queueForAudits') + ->inject('auditContext') + ->inject('publisherForAudits') ->inject('usage') ->inject('publisherForUsage') ->inject('queueForDeletes') @@ -791,7 +809,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -886,7 +904,7 @@ Http::shutdown() if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); if (! empty($resource) && $resource !== $pattern) { - $queueForAudits->setResource($resource); + $auditContext->resource = $resource; } } @@ -896,8 +914,8 @@ Http::shutdown() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $queueForAudits->setUser($userClone); - } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) { + $auditContext->user = $userClone; + } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { /** * User in the request is empty, and no user was set for auditing previously. * This indicates: @@ -915,24 +933,21 @@ Http::shutdown() 'name' => 'Guest', ]); - $queueForAudits->setUser($user); + $auditContext->user = $user; } - if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) { + $auditUser = $auditContext->user; + if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { - $queueForAudits->setPayload($responsePayload); + $auditContext->payload = $responsePayload; } - foreach ($queueForEvents->getParams() as $key => $value) { - $queueForAudits->setParam($key, $value); - } - - $queueForAudits->trigger(); + $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext)); } if (! empty($queueForDeletes->getType())) { diff --git a/app/http.php b/app/http.php index 67da67376d..afcc2d2d0f 100644 --- a/app/http.php +++ b/app/http.php @@ -72,8 +72,6 @@ $swooleAdapter = new Server( container: $container, ); -$container->set('container', fn () => fn () => $swooleAdapter->getContainer()); - $http = $swooleAdapter->getServer(); /** @@ -533,6 +531,7 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files } $requestContainer = $swooleAdapter->getContainer(); + $requestContainer->set('container', fn () => $requestContainer); $requestContainer->set('request', fn () => $request); $requestContainer->set('response', fn () => $response); diff --git a/app/init/configs.php b/app/init/configs.php index 35c8e3899d..360a7abc34 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -12,7 +12,7 @@ Config::load('runtimes-v2', __DIR__ . '/../config/runtimes-v2.php', $configAdapt Config::load('template-runtimes', __DIR__ . '/../config/template-runtimes.php', $configAdapter); Config::load('events', __DIR__ . '/../config/events.php', $configAdapter); Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter); -Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs +Config::load('protocols', __DIR__ . '/../config/protocols.php', $configAdapter); Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter); Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter); Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter); diff --git a/app/init/constants.php b/app/init/constants.php index b375f801f8..5db8411e36 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,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 = 4321; -const APP_VERSION_STABLE = '1.9.0'; +const APP_CACHE_BUSTER = 4322; +const APP_VERSION_STABLE = '1.9.1'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php new file mode 100644 index 0000000000..0c1dbad923 --- /dev/null +++ b/app/init/realtime/connection.php @@ -0,0 +1,366 @@ +getHeader('x-appwrite-project', ''); + + if (!empty($projectId)) { + return $projectId; + } + + $projectId = $request->getParam('project', ''); + + return \is_string($projectId) ? $projectId : ''; + }; + + $getMode = static function (Request $request, Document $project) use ($getProjectId): string { + $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT)); + $projectId = $getProjectId($request); + + if (!empty($projectId) && $project->getId() !== $projectId) { + $mode = APP_MODE_ADMIN; + } + + return $mode; + }; + + $getDbForPlatform = static function (Authorization $authorization) { + $database = getConsoleDB(); + $database->setAuthorization($authorization); + + return $database; + }; + + $getDbForProject = static function (Document $project, Authorization $authorization) use ($getDbForPlatform) { + if ($project->isEmpty() || $project->getId() === 'console') { + return $getDbForPlatform($authorization); + } + + $database = getProjectDB($project); + $database->setAuthorization($authorization); + + return $database; + }; + + $findRule = static function (Request $request, Document $project, Authorization $authorization) use ($getDbForPlatform): Document { + $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); + + if (empty($domain)) { + $domain = \parse_url($request->getReferer(), PHP_URL_HOST); + } + + if (empty($domain)) { + return new Document(); + } + + $dbForPlatform = $getDbForPlatform($authorization); + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); + } + + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); + + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; + } + + $trustedProjects[] = $trustedProject; + } + + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects, true)) { + $permitsCurrentProject = true; + } + } + + if (!$permitsCurrentProject) { + return new Document(); + } + + return $rule; + }; + + $findDevKey = static function (Request $request, Document $project, array $servers, Authorization $authorization) use ($getDbForPlatform): Document { + $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', '')); + $key = $project->find('secret', $devKey, 'devKeys'); + + if (!$key) { + return new Document([]); + } + + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + return new Document([]); + } + + $dbForPlatform = $getDbForPlatform($authorization); + $accessedAt = $key->getAttribute('accessedAt', 0); + + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) { + $key->setAttribute('accessedAt', DatabaseDateTime::now()); + $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'accessedAt' => $key->getAttribute('accessedAt'), + ]))); + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } + + $sdkValidator = new WhiteList($servers, true); + $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); + + if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + $sdks = $key->getAttribute('sdks', []); + + if (!\in_array($sdk, $sdks, true)) { + $sdks[] = $sdk; + $key->setAttribute('sdks', $sdks); + $key->setAttribute('accessedAt', DatabaseDateTime::now()); + + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'sdks' => $key->getAttribute('sdks'), + 'accessedAt' => $key->getAttribute('accessedAt'), + ]))); + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } + } + + return $key; + }; + + $container->set('authorization', function () { + return new Authorization(); + }, []); + + $container->set('project', function (Request $request, Document $console, Authorization $authorization) use ($getProjectId, $getDbForPlatform) { + $projectId = $getProjectId($request); + + if (empty($projectId) || $projectId === 'console') { + return $console; + } + + $dbForPlatform = $getDbForPlatform($authorization); + + return $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + }, ['request', 'console', 'authorization']); + + $container->set('originValidator', function (array $platform, Request $request, Document $project, array $servers, Authorization $authorization) use ($findDevKey, $findRule) { + $devKey = $findDevKey($request, $project, $servers, $authorization); + + if (!$devKey->isEmpty()) { + return new URL(); + } + + $allowedHostnames = [...($platform['hostnames'] ?? [])]; + if (!$project->isEmpty() && $project->getId() !== 'console') { + $allowedHostnames = [...$allowedHostnames, ...Platform::getHostnames($project->getAttribute('platforms', []))]; + } + + $rule = $findRule($request, $project, $authorization); + if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) { + $allowedHostnames[] = $rule->getAttribute('domain', ''); + } + + $originHostname = \parse_url($request->getOrigin(), PHP_URL_HOST); + $refererHostname = \parse_url($request->getReferer(), PHP_URL_HOST); + $hostname = $originHostname ?: $refererHostname; + + if ($request->getMethod() === 'OPTIONS' && !empty($hostname)) { + $allowedHostnames[] = $hostname; + } + + $allowedSchemes = [...($platform['schemas'] ?? [])]; + if (!$project->isEmpty() && $project->getId() !== 'console') { + $allowedSchemes[] = 'exp'; + $allowedSchemes[] = 'appwrite-callback-' . $project->getId(); + $allowedSchemes = [...$allowedSchemes, ...Platform::getSchemes($project->getAttribute('platforms', []))]; + } + + return new Origin(\array_unique($allowedHostnames), \array_unique($allowedSchemes)); + }, ['platform', 'request', 'project', 'servers', 'authorization']); + + $container->set('user', function (Request $request, Document $project, Document $console, Authorization $authorization) use ($getMode, $getDbForPlatform, $getDbForProject) { + $mode = $getMode($request, $project); + $store = new Store(); + $proofForToken = new Token(); + $proofForToken->setHash(new Sha()); + + $authorization->setDefaultStatus(true); + + $dbForPlatform = $getDbForPlatform($authorization); + $dbForProject = $getDbForProject($project, $authorization); + + $store->setKey('a_session_' . $project->getId()); + if ($mode === APP_MODE_ADMIN) { + $store->setKey('a_session_' . $console->getId()); + } + + $store->decode( + $request->getCookie( + $store->getKey(), + $request->getCookie($store->getKey() . '_legacy', '') + ) + ); + + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + $sessionHeader = $request->getHeader('x-appwrite-session', ''); + + if (!empty($sessionHeader)) { + $store->decode($sessionHeader); + } + } + + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + $fallback = \json_decode($request->getHeader('x-fallback-cookies', ''), true); + $store->decode((\is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''); + } + + $user = null; + if ($mode === APP_MODE_ADMIN) { + /** @var User $user */ + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + if ($project->isEmpty()) { + $user = new User([]); + } elseif (!empty($store->getProperty('id', ''))) { + if ($project->getId() === 'console') { + /** @var User $user */ + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + /** @var User $user */ + $user = $dbForProject->getDocument('users', $store->getProperty('id', '')); + } + } + } + + if ( + !$user + || $user->isEmpty() + || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) + ) { + $user = new User([]); + } + + $authJWT = $request->getHeader('x-appwrite-jwt', ''); + if (!empty($authJWT) && !$project->isEmpty()) { + if (!$user->isEmpty()) { + throw new Exception(Exception::USER_JWT_AND_COOKIE_SET); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); + + try { + $payload = $jwt->decode($authJWT); + } catch (JWTException $error) { + throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); + } + + $jwtUserId = $payload['userId'] ?? ''; + if (!empty($jwtUserId)) { + if ($mode === APP_MODE_ADMIN) { + $user = $dbForPlatform->getDocument('users', $jwtUserId); + } else { + $user = $dbForProject->getDocument('users', $jwtUserId); + } + } + + $jwtSessionId = $payload['sessionId'] ?? ''; + if (!empty($jwtSessionId) && empty($user->find('$id', $jwtSessionId, 'sessions'))) { + $user = new User([]); + } + } + + $accountKey = $request->getHeader('x-appwrite-key', ''); + $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); + + if (!empty($accountKeyUserId) && !empty($accountKey)) { + if (!$user->isEmpty()) { + throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); + } + + $accountKeyUser = $authorization->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); + if (!$accountKeyUser->isEmpty()) { + $key = $accountKeyUser->find( + key: 'secret', + find: $accountKey, + subject: 'keys' + ); + + if (!empty($key)) { + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); + } + + $user = $accountKeyUser; + } + } + } + + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + + if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { + $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; + $targetUser = null; + + if (!empty($impersonateUserId)) { + $targetUser = $authorization->skip(fn () => $userDb->getDocument('users', $impersonateUserId)); + } elseif (!empty($impersonateEmail)) { + $targetUser = $authorization->skip(fn () => $userDb->findOne('users', [ + Query::equal('email', [\strtolower($impersonateEmail)]), + ])); + } elseif (!empty($impersonatePhone)) { + $targetUser = $authorization->skip(fn () => $userDb->findOne('users', [ + Query::equal('phone', [$impersonatePhone]), + ])); + } + + if ($targetUser !== null && !$targetUser->isEmpty()) { + $impersonator = clone $user; + $user = clone $targetUser; + $user->setAttribute('impersonatorUserId', $impersonator->getId()); + $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence()); + $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', '')); + $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', '')); + $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0)); + } + } + + $dbForPlatform->setMetadata('user', $user->getId()); + $dbForProject->setMetadata('user', $user->getId()); + + return $user; + }, ['request', 'project', 'console', 'authorization']); +}; diff --git a/app/init/resources.php b/app/init/resources.php index fdca88c30e..d1bb7584bf 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1,6 +1,12 @@ set('publisherMessaging', function (Publisher $publisher) { $container->set('publisherWebhooks', function (Publisher $publisher) { return $publisher; }, ['publisher']); +$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher( + $publisher, + new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher( + $publisher, + new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher( + $publisher, + new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME)) +), ['publisher']); $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher( + $publisher, + new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher( + $publisher, + new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) +), ['publisher']); /** * Platform configuration diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 156e151501..3f6196c460 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -4,19 +4,15 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Databases\TransactionState; -use Appwrite\Event\Audit as AuditEvent; use Appwrite\Event\Build; -use Appwrite\Event\Certificate; +use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; -use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -130,9 +126,6 @@ return function (Container $container): void { $container->set('queueForBuilds', function (Publisher $publisher) { return new Build($publisher); }, ['publisher']); - $container->set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); $container->set('queueForDatabase', function (Publisher $publisher) { return new EventDatabase($publisher); }, ['publisher']); @@ -151,25 +144,13 @@ return function (Container $container): void { $container->set('usage', function () { return new UsageContext(); }, []); - $container->set('queueForAudits', function (Publisher $publisher) { - return new AuditEvent($publisher); - }, ['publisher']); + $container->set('auditContext', fn () => new AuditContext(), []); $container->set('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); $container->set('eventProcessor', function () { return new EventProcessor(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); - }, ['publisher']); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); - }, ['publisher']); - $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); $database = new Database($adapter, $cache); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 95477088ce..c505d4cb3a 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,17 +1,13 @@ set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); - $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); @@ -324,10 +316,6 @@ return function (Container $container): void { return new Event($publisher); }, ['publisher']); - $container->set('queueForAudits', function (Publisher $publisher) { - return new Audit($publisher); - }, ['publisher']); - $container->set('queueForWebhooks', function (Publisher $publisher) { return new Webhook($publisher); }, ['publisher']); @@ -340,14 +328,6 @@ return function (Container $container): void { return new Realtime(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); - }, ['publisher']); - - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); }, ['project', 'telemetry']); diff --git a/app/listeners.php b/app/listeners.php index 714c255974..240225dc45 100644 --- a/app/listeners.php +++ b/app/listeners.php @@ -1,9 +1,11 @@ onWorkerStart(function (int $workerId) use ($server, $register, $stats, ] ]; - $server->send($realtime->getSubscribers($event), json_encode([ + $server->send(array_keys($realtime->getSubscribers($event)), json_encode([ 'type' => 'event', 'data' => $event['data'] ])); @@ -557,7 +555,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $receivers = $realtime->getSubscribers($event); - if (Http::isDevelopment() && !empty($receivers)) { + if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) { Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); Console::log("[Debug][Worker {$workerId}] Connection IDs: " . json_encode(array_keys($receivers))); Console::log("[Debug][Worker {$workerId}] Matched: " . json_encode(array_values($receivers))); @@ -623,7 +621,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); -$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerRequestResources) { +$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) { global $container; $request = new Request($request); $response = new Response(new SwooleResponse()); @@ -631,14 +629,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::info("Connection open (user: {$connection})"); $connectionContainer = new Container($container); - - $adapter = new HttpServer($connectionContainer); - $app = new Http($adapter, 'UTC'); - $connectionContainer->set('utopia', fn () => $app); $connectionContainer->set('request', fn () => $request); - $connectionContainer->set('response', fn () => $response); - $registerRequestResources($connectionContainer); + $registerConnectionResources($connectionContainer); $project = null; $logUser = null; @@ -646,8 +639,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, try { /** @var Document $project */ - $project = $app->getResource('project'); - $authorization = $app->getResource('authorization'); + $project = $connectionContainer->get('project'); + $authorization = $connectionContainer->get('authorization'); /* * Project Check @@ -656,13 +649,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID'); } - $timelimit = $app->getResource('timelimit'); - $user = $app->getResource('user'); /** @var User $user */ + $timelimit = $connectionContainer->get('timelimit'); + $user = $connectionContainer->get('user'); /** @var User $user */ $logUser = $user; + $apis = $project->getAttribute('apis', []); + // Websocket is what to check, but realtime is checked too for backwards compatibility + $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true; if ( - array_key_exists('realtime', $project->getAttribute('apis', [])) - && !$project->getAttribute('apis', [])['realtime'] + !$websocketEnabled && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); @@ -702,7 +697,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, * Skip this check for non-web platforms which are not required to send an origin header. */ $origin = $request->getOrigin(); - $originValidator = $app->getResource('originValidator'); + $originValidator = $connectionContainer->get('originValidator'); if (!empty($origin) && !$originValidator->isValid($origin) && $project->getId() !== 'console') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription()); @@ -712,11 +707,43 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { + $register->get('telemetry.connectionCounter')->add(1); + $register->get('telemetry.connectionCreatedCounter')->add(1); + + $stats->set($projectId, [ + 'projectId' => $projectId, + 'teamId' => $teamId + ]); + $stats->incr($projectId, 'connections'); + $stats->incr($projectId, 'connectionsTotal'); + + triggerStats([ + METRIC_REALTIME_CONNECTIONS => 1, + METRIC_REALTIME_OUTBOUND => \strlen($payloadJson), + ], $projectId); + }; + /** * Channels Check */ if (empty($channels)) { - throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels'); + // in case of message based 'subscribe' channels will be empty at first and only projectId and roles will be available + $sanitizedUser = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT); + $connectedPayloadJson = json_encode([ + 'type' => 'connected', + 'data' => [ + 'channels' => [], + 'subscriptions' => [], + 'user' => $sanitizedUser + ] + ]); + + $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); + $realtime->connections[$connection]['authorization'] = $authorization; + $server->send([$connection], $connectedPayloadJson); + $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + return; } $names = array_keys($channels); @@ -740,7 +767,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $subscriptionId, $roles, $subscription['channels'], - $subscription['queries'] + $subscription['queries'], + $user->getId() ); $mapping[$index] = $subscriptionId; @@ -760,20 +788,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); - - $register->get('telemetry.connectionCounter')->add(1); - $register->get('telemetry.connectionCreatedCounter')->add(1); - - $stats->set($project->getId(), [ - 'projectId' => $project->getId(), - 'teamId' => $project->getAttribute('teamId') - ]); - $stats->incr($project->getId(), 'connections'); - $stats->incr($project->getId(), 'connectionsTotal'); - - $connectedOutboundBytes = \strlen($connectedPayloadJson); - - triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId()); + $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); } catch (Throwable $th) { @@ -789,7 +804,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, // sanitize 0 && 5xx errors $realtimeViolation = $th instanceof AppwriteException && $th->getType() === AppwriteException::REALTIME_POLICY_VIOLATION; - if (($code === 0 || $code >= 500) && !$realtimeViolation && !Http::isDevelopment()) { + if (($code === 0 || $code >= 500) && !$realtimeViolation && System::getEnv('_APP_ENV', 'production') !== 'development') { $message = 'Error: Server Error'; } @@ -804,7 +819,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->send([$connection], json_encode($response)); $server->close($connection, $code); - if (Http::isDevelopment()) { + if (System::getEnv('_APP_ENV', 'production') === 'development') { Console::error('[Error] Connection Error'); Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); @@ -815,7 +830,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { $project = null; $authorization = null; - try { $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); @@ -941,7 +955,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionId, $roles, $subscription['channels'] ?? [], - $queries + $queries, + $user->getId() ); } } @@ -975,6 +990,99 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; + case 'subscribe': + /** + * Message based upsertion of a subscription + * If subscriptionId is given then it will match subId of the connection and update the subscription with channels and queries + * If non-existing subid is given or not given a new subid will be generated + * Similar to what we have now -> two subscribe() block with same channels and queries still two different subscriptions + * + * structure of the payload -> array of maps + * 'data' : [subscriptionId:"" , channels:[] , queries:[]] + */ + if (!is_array($message['data']) || !array_is_list($message['data'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + } + + $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()]; + $userId = $realtime->connections[$connection]['userId'] ?? ''; + + // bulk validation + parsing before subscribing + $parsedPayloads = []; + foreach ($message['data'] as $payload) { + if (!\is_array($payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.'); + } + if (!array_key_exists('channels', $payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); + } + if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); + } + // registering the queries if not present and check in the same payload later on + if (!array_key_exists('queries', $payload)) { + $payload['queries'] = []; + } + if (!is_array($payload['queries']) || !array_is_list($payload['queries'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.'); + } + + $subscriptionId = \array_key_exists('subscriptionId', $payload) + ? $payload['subscriptionId'] + : ID::unique(); + + try { + $convertedQueries = Realtime::convertQueries($payload['queries']); + } catch (QueryException $e) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); + } + + $parsedPayloads[] = [ + 'subscriptionId' => $subscriptionId, + 'channels' => $payload['channels'], + 'queries' => $convertedQueries, + ]; + } + + foreach ($parsedPayloads as $parsedPayload) { + $subscriptionId = $parsedPayload['subscriptionId']; + $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); + $queries = $parsedPayload['queries']; + $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); + } + + // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. + $realtime->connections[$connection]['authorization'] = $authorization; + + $responsePayload = json_encode([ + 'type' => 'response', + 'data' => [ + 'to' => 'subscribe', + 'success' => true, + 'subscriptions' => \array_map(function (array $parsedPayload) { + return [ + 'subscriptionId' => $parsedPayload['subscriptionId'], + 'channels' => $parsedPayload['channels'], + 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), + ]; + }, $parsedPayloads), + ] + ]); + + $server->send([$connection], $responsePayload); + + if ($project !== null && !$project->isEmpty()) { + $subscribeOutboundBytes = \strlen($responsePayload); + + if ($subscribeOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $subscribeOutboundBytes, + ], $project->getId()); + } + } + + break; + default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } @@ -988,7 +1096,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $message = $th->getMessage(); // sanitize 0 && 5xx errors - if (($code === 0 || $code >= 500) && !Http::isDevelopment()) { + if (($code === 0 || $code >= 500) && System::getEnv('_APP_ENV', 'production') !== 'development') { $message = 'Error: Server Error'; } diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 741d085445..ef4d4a1fe4 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -532,6 +532,35 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG + appwrite-worker-executions: + image: /: + entrypoint: worker-executions + <<: *x-logging + container_name: appwrite-worker-executions + restart: unless-stopped + networks: + - appwrite + depends_on: + redis: + condition: service_healthy + : + condition: service_healthy + environment: + - _APP_ENV + - _APP_WORKER_PER_CORE + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_ENV + - _APP_DB_ADAPTER + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_LOGGING_CONFIG + appwrite-worker-functions: image: /: entrypoint: worker-functions @@ -964,7 +993,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); <<: *x-logging restart: unless-stopped stop_signal: SIGINT - image: openruntimes/executor:0.7.22 + image: openruntimes/executor:0.11.4 networks: - appwrite - runtimes diff --git a/app/worker.php b/app/worker.php index e55abb587c..7cc34f397c 100644 --- a/app/worker.php +++ b/app/worker.php @@ -1,8 +1,8 @@ $user + * @param array $project + * @param array $session + */ + public function __construct( + public readonly array $user, + public readonly array $project, + public readonly array $session, + public readonly string $locale, + ) { + } +} diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php index 9bd539d5fe..585d4b09a7 100644 --- a/src/Appwrite/Bus/Listeners/Log.php +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -3,10 +3,12 @@ namespace Appwrite\Bus\Listeners; use Appwrite\Bus\Events\ExecutionCompleted; -use Appwrite\Event\Execution; +use Appwrite\Event\Message\Execution as ExecutionMessage; +use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Utopia\Bus\Listener; use Utopia\Database\Document; -use Utopia\Queue\Publisher; +use Utopia\Span\Span; +use Utopia\System\System; class Log extends Listener { @@ -24,16 +26,33 @@ class Log extends Listener { $this ->desc('Persists execution logs to database via queue') - ->inject('publisher') + ->inject('publisherForExecutions') ->callback($this->handle(...)); } - public function handle(ExecutionCompleted $event, Publisher $publisher): void + public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void { - $queueForExecutions = new Execution($publisher); - $queueForExecutions - ->setExecution(new Document($event->execution)) - ->setProject(new Document($event->project)) - ->trigger(); + $project = new Document($event->project); + $execution = new Document($event->execution); + if ($execution->getAttribute('resourceType', '') === 'functions') { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.v1_executions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + } + + $publisherForExecutions->enqueue(new ExecutionMessage( + project: $project, + execution: $execution, + )); } } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php new file mode 100644 index 0000000000..2ffcbc9aa4 --- /dev/null +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -0,0 +1,154 @@ +desc('Sends session alert emails') + ->inject('publisher') + ->inject('locale') + ->inject('platform') + ->inject('dbForProject') + ->callback($this->handle(...)); + } + + public function handle(SessionCreated $event, Publisher $publisher, Locale $locale, array $platform, Database $dbForProject): void + { + $project = new Document($event->project); + + if (!($project->getAttribute('auths', [])['sessionAlerts'] ?? false)) { + return; + } + + if (empty($event->user['email'])) { + return; + } + + $provider = $event->session['provider'] ?? ''; + $factors = $event->session['factors'] ?? []; + + if (\in_array($provider, [SESSION_PROVIDER_MAGIC_URL, SESSION_PROVIDER_TOKEN]) && \in_array(Type::EMAIL, $factors)) { + return; + } + + if ($dbForProject->count('sessions', [Query::equal('userId', [$event->user['$id']])]) === 1) { + return; + } + + $locale->setDefault($event->locale); + + $session = new Document($event->session); + $smtp = $project->getAttribute('smtp', []); + $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); + + if (!(new FileName())->isValid($smtpBaseTemplate)) { + throw new \Exception('Invalid template path'); + } + + $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? []; + $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; + + $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); + $preview = $locale->getText('emails.sessionAlert.preview'); + + $body = empty($customTemplate['message']) + ? Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-session-alert.tpl') + ->setParam('{{hello}}', $locale->getText('emails.sessionAlert.hello')) + ->setParam('{{body}}', $locale->getText('emails.sessionAlert.body')) + ->setParam('{{listDevice}}', $locale->getText('emails.sessionAlert.listDevice')) + ->setParam('{{listIpAddress}}', $locale->getText('emails.sessionAlert.listIpAddress')) + ->setParam('{{listCountry}}', $locale->getText('emails.sessionAlert.listCountry')) + ->setParam('{{footer}}', $locale->getText('emails.sessionAlert.footer')) + ->setParam('{{thanks}}', $locale->getText('emails.sessionAlert.thanks')) + ->setParam('{{signature}}', $locale->getText('emails.sessionAlert.signature')) + ->render() + : $customTemplate['message']; + + $clientName = $session->getAttribute('clientName') + ?: ($session->getAttribute('userAgent') ?: 'UNKNOWN'); + + $projectName = $project->getId() === 'console' + ? $platform['platformName'] + : $project->getAttribute('name'); + + $emailVariables = [ + 'direction' => $locale->getText('settings.direction'), + 'date' => (new \DateTime())->format('F j'), + 'year' => (new \DateTime())->format('YYYY'), + 'time' => (new \DateTime())->format('H:i:s'), + 'user' => $event->user['name'] ?? '', + 'project' => $projectName, + 'device' => $clientName, + 'ipAddress' => $session->getAttribute('ip'), + 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')), + ]; + + if ($isBranded) { + $emailVariables += [ + 'accentColor' => $platform['accentColor'], + 'logoUrl' => $platform['logoUrl'], + 'twitter' => $platform['twitterUrl'], + 'discord' => $platform['discordUrl'], + 'github' => $platform['githubUrl'], + 'terms' => $platform['termsUrl'], + 'privacy' => $platform['privacyUrl'], + 'platform' => $platform['platformName'], + ]; + } + + $queueForMails = new Mail($publisher); + + if ($smtp['enabled'] ?? false) { + $queueForMails + ->setSmtpHost($smtp['host'] ?? '') + ->setSmtpPort($smtp['port'] ?? '') + ->setSmtpUsername($smtp['username'] ?? '') + ->setSmtpPassword($smtp['password'] ?? '') + ->setSmtpSecure($smtp['secure'] ?? '') + ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '') + ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) + ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); + } + + $queueForMails + ->setProject($project) + ->setSubject($subject) + ->setPreview($preview) + ->setBody($body) + ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl') + ->appendVariables($emailVariables) + ->setRecipient($event->user['email']); + + if ($isBranded) { + $queueForMails->setSenderName($platform['emailSenderName']); + } + + $queueForMails->trigger(); + } +} diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php new file mode 100644 index 0000000000..1d41890476 --- /dev/null +++ b/src/Appwrite/Event/Context/Audit.php @@ -0,0 +1,34 @@ +project === null + && $this->user === null + && $this->mode === '' + && $this->userAgent === '' + && $this->ip === '' + && $this->hostname === '' + && $this->event === '' + && $this->resource === '' + && $this->payload === []; + } +} diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index ae75e3924f..fae2d0e843 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -637,9 +637,11 @@ class Event */ $eventValues = \array_values($events); - /** - * Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility - */ + $databaseType = $database?->getAttribute('type', 'legacy'); + if ($database !== null && !\in_array($databaseType, ['legacy', 'tablesdb'], true)) { + return $eventValues; + } + return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues); } @@ -662,21 +664,30 @@ class Event } /** - * Adds `table` events for `collection` events. + * Adds table/collection counterpart events for backward compatibility. * * Example: * * `databases.*.collections.*.documents.*.update` →\ * `[databases.*.collections.*.documents.*.update, databases.*.tables.*.rows.*.update]` + * + * `databases.*.tables.*.rows.*.update` →\ + * `[databases.*.tables.*.rows.*.update, databases.*.collections.*.documents.*.update]` */ private static function mirrorCollectionEvents(string $pattern, string $firstEvent, array $events): array { - $tableEventMap = [ + $collectionsToTablesMap = [ 'documents' => 'rows', 'collections' => 'tables', 'attributes' => 'columns', ]; + $tablesToCollectionsMap = [ + 'rows' => 'documents', + 'tables' => 'collections', + 'columns' => 'attributes', + ]; + $databasesEventMap = [ 'tablesdb' => 'databases', 'tables' => 'collections', @@ -687,7 +698,10 @@ class Event if ( ( str_contains($pattern, 'databases.') && - str_contains($firstEvent, 'collections') + ( + str_contains($firstEvent, 'collections') || + str_contains($firstEvent, 'tables') + ) ) || ( str_contains($firstEvent, 'tablesdb.') @@ -698,25 +712,16 @@ class Event $pairedEvents[] = $event; // tablesdb needs databases event with tables and collections if (str_contains($event, 'tablesdb')) { - $databasesSideEvent = str_replace( - array_keys($databasesEventMap), - array_values($databasesEventMap), - $event - ); + $databasesSideEvent = self::replaceEventSegments($event, $databasesEventMap); $pairedEvents[] = $databasesSideEvent; - $tableSideEvent = str_replace( - array_keys($tableEventMap), - array_values($tableEventMap), - $databasesSideEvent - ); + $tableSideEvent = self::replaceEventSegments($databasesSideEvent, $collectionsToTablesMap); $pairedEvents[] = $tableSideEvent; } elseif (str_contains($event, 'collections')) { - $tableSideEvent = str_replace( - array_keys($tableEventMap), - array_values($tableEventMap), - $event - ); + $tableSideEvent = self::replaceEventSegments($event, $collectionsToTablesMap); $pairedEvents[] = $tableSideEvent; + } elseif (str_contains($event, 'tables')) { + $collectionSideEvent = self::replaceEventSegments($event, $tablesToCollectionsMap); + $pairedEvents[] = $collectionSideEvent; } } @@ -728,6 +733,20 @@ class Event return array_values(array_unique($events)); } + /** + * Replace only exact event path segments, never partial substrings. + */ + private static function replaceEventSegments(string $event, array $map): string + { + $parts = \explode('.', $event); + $parts = \array_map( + fn (string $part) => $map[$part] ?? $part, + $parts + ); + + return \implode('.', $parts); + } + /** * Maps event terminology based on database type */ diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php index 398025565c..9e735991ba 100644 --- a/src/Appwrite/Event/Execution.php +++ b/src/Appwrite/Event/Execution.php @@ -53,4 +53,23 @@ class Execution extends Event 'execution' => $this->execution, ]; } + + /** + * Trim payload for the execution event. + * Only the project ID is needed — the worker DI fetches the full project from the platform database. + * + * @return array + */ + protected function trimPayload(): array + { + $trimmed = []; + + if ($this->project) { + $trimmed['project'] = new Document([ + '$id' => $this->project->getId(), + ]); + } + + return $trimmed; + } } diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index 38d7a27c11..d8f25489c6 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -101,7 +101,6 @@ class Mail extends Event /** * Sets preview for the mail event. * - * @param string $preview * @return self */ public function setPreview(string $preview): self diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php new file mode 100644 index 0000000000..ae5831c3b9 --- /dev/null +++ b/src/Appwrite/Event/Message/Audit.php @@ -0,0 +1,71 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'user' => $this->user->getArrayCopy(), + 'payload' => $this->payload, + 'resource' => $this->resource, + 'mode' => $this->mode, + 'ip' => $this->ip, + 'userAgent' => $this->userAgent, + 'event' => $this->event, + 'hostname' => $this->hostname, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + event: $data['event'] ?? '', + payload: $data['payload'] ?? [], + project: new Document($data['project'] ?? []), + user: new Document($data['user'] ?? []), + resource: $data['resource'] ?? '', + mode: $data['mode'] ?? '', + ip: $data['ip'] ?? '', + userAgent: $data['userAgent'] ?? '', + hostname: $data['hostname'] ?? '', + ); + } + + public static function fromContext(AuditContext $context): static + { + return new self( + event: $context->event, + payload: $context->payload, + project: $context->project ?? new Document(), + user: $context->user ?? new Document(), + resource: $context->resource, + mode: $context->mode, + ip: $context->ip, + userAgent: $context->userAgent, + hostname: $context->hostname, + ); + } +} diff --git a/src/Appwrite/Event/Message/Certificate.php b/src/Appwrite/Event/Message/Certificate.php new file mode 100644 index 0000000000..a189bb8187 --- /dev/null +++ b/src/Appwrite/Event/Message/Certificate.php @@ -0,0 +1,43 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'domain' => $this->domain->getArrayCopy(), + 'skipRenewCheck' => $this->skipRenewCheck, + 'validationDomain' => $this->validationDomain, + 'action' => $this->action, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + domain: new Document($data['domain'] ?? []), + skipRenewCheck: $data['skipRenewCheck'] ?? false, + validationDomain: $data['validationDomain'] ?? null, + action: $data['action'] ?? \Appwrite\Event\Certificate::ACTION_GENERATION, + ); + } +} diff --git a/src/Appwrite/Event/Message/Execution.php b/src/Appwrite/Event/Message/Execution.php new file mode 100644 index 0000000000..0943c82e4a --- /dev/null +++ b/src/Appwrite/Event/Message/Execution.php @@ -0,0 +1,30 @@ + $this->project->getArrayCopy(), + 'execution' => $this->execution->getArrayCopy(), + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + execution: new Document($data['execution'] ?? []), + ); + } +} diff --git a/src/Appwrite/Event/Message/Migration.php b/src/Appwrite/Event/Message/Migration.php new file mode 100644 index 0000000000..ceeec45461 --- /dev/null +++ b/src/Appwrite/Event/Message/Migration.php @@ -0,0 +1,33 @@ + $this->project->getArrayCopy(), + 'migration' => $this->migration->getArrayCopy(), + 'platform' => $this->platform, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + migration: new Document($data['migration'] ?? []), + platform: $data['platform'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php new file mode 100644 index 0000000000..a06cdfbfc0 --- /dev/null +++ b/src/Appwrite/Event/Message/Screenshot.php @@ -0,0 +1,34 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'deploymentId' => $this->deploymentId, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + deploymentId: $data['deploymentId'] ?? '', + ); + } +} diff --git a/src/Appwrite/Event/Message/StatsResources.php b/src/Appwrite/Event/Message/StatsResources.php new file mode 100644 index 0000000000..584cbc137a --- /dev/null +++ b/src/Appwrite/Event/Message/StatsResources.php @@ -0,0 +1,27 @@ + $this->project->getArrayCopy(), + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Audit.php b/src/Appwrite/Event/Publisher/Audit.php new file mode 100644 index 0000000000..daa9a01fce --- /dev/null +++ b/src/Appwrite/Event/Publisher/Audit.php @@ -0,0 +1,35 @@ +publish($this->queue, $message); + } catch (\Throwable $th) { + Console::error('[Audit] Failed to publish audit message: ' . $th->getMessage()); + + return false; + } + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Certificate.php b/src/Appwrite/Event/Publisher/Certificate.php new file mode 100644 index 0000000000..472fb0d701 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Certificate.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Execution.php b/src/Appwrite/Event/Publisher/Execution.php new file mode 100644 index 0000000000..05ea28d540 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Execution.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Migration.php b/src/Appwrite/Event/Publisher/Migration.php new file mode 100644 index 0000000000..fc455a7e95 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Migration.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Screenshot.php b/src/Appwrite/Event/Publisher/Screenshot.php new file mode 100644 index 0000000000..2a0fa1e0f8 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Screenshot.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/StatsResources.php b/src/Appwrite/Event/Publisher/StatsResources.php new file mode 100644 index 0000000000..4c04583b15 --- /dev/null +++ b/src/Appwrite/Event/Publisher/StatsResources.php @@ -0,0 +1,34 @@ +publish($this->queue, $message); + } catch (\Throwable $th) { + Console::error('[StatsResources] Failed to publish stats resources message: ' . $th->getMessage()); + return false; + } + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 747fd786f9..f040d91468 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -61,6 +61,26 @@ class Realtime extends Event return $this->subscribers; } + /** + * Reset the event state for long-lived worker processes. + * + * `Event::reset()` clears params/sensitive/event/payload only. Realtime routing also + * depends on `context`, `subscribers`, and `project`/`user` fields, so we clear them too + * to prevent stale state from affecting subsequent triggers. + */ + public function reset(): self + { + parent::reset(); + + $this->subscribers = []; + $this->context = []; + $this->project = null; + $this->user = null; + $this->userId = null; + + return $this; + } + /** * Execute Event. * diff --git a/src/Appwrite/GraphQL/Promises/Adapter.php b/src/Appwrite/GraphQL/Promises/Adapter.php index 86270f2a8b..1d9cc4557f 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter.php +++ b/src/Appwrite/GraphQL/Promises/Adapter.php @@ -81,8 +81,8 @@ abstract class Adapter implements PromiseAdapter /** * Create a new promise that resolves when all passed in promises resolve. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return GQLPromise */ - abstract public function all(array $promisesOrValues): GQLPromise; + abstract public function all(iterable $promisesOrValues): GQLPromise; } diff --git a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php index efe6eb2f50..af6441ad6d 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php +++ b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php @@ -35,8 +35,12 @@ class Swoole extends Adapter return new GQLPromise($promise, $this); } - public function all(array $promisesOrValues): GQLPromise + public function all(iterable $promisesOrValues): GQLPromise { + if ($promisesOrValues instanceof \Traversable) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + return new GQLPromise(SwoolePromise::all($promisesOrValues), $this); } } diff --git a/src/Appwrite/GraphQL/ResolverLock.php b/src/Appwrite/GraphQL/ResolverLock.php new file mode 100644 index 0000000000..b1cdcf3d53 --- /dev/null +++ b/src/Appwrite/GraphQL/ResolverLock.php @@ -0,0 +1,55 @@ +channel = new Channel(1); + } + + /** + * Acquire the lock. Re-entering from the same coroutine only + * increments depth to avoid self-deadlock. + */ + public function acquire(): void + { + $cid = Coroutine::getCid(); + + if ($this->owner === $cid) { + $this->depth++; + return; + } + + $this->channel->push(true); + $this->owner = $cid; + $this->depth = 1; + } + + /** + * Release the lock. + */ + public function release(): void + { + if ($this->owner !== Coroutine::getCid()) { + return; + } + + $this->depth--; + + if ($this->depth > 0) { + return; + } + + $this->owner = null; + $this->channel->pop(); + } +} diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 689724d9f1..cabb357607 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,6 +6,7 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; +use Utopia\DI\Container; use Utopia\Http\Exception; use Utopia\Http\Http; use Utopia\Http\Route; @@ -13,6 +14,75 @@ use Utopia\System\System; class Resolvers { + /** + * Request-scoped locks keyed by the per-request GraphQL Http instance. + * + * @var array + */ + private static array $locks = []; + + /** + * Preserve response side effects that callers depend on, such as session + * cookies created by account auth routes. + */ + private static function mergeResponseSideEffects(Response $from, Response $to): void + { + foreach ($from->getCookies() as $cookie) { + $to->removeCookie($cookie['name']); + $to->addCookie( + $cookie['name'], + $cookie['value'], + $cookie['expire'], + $cookie['path'], + $cookie['domain'], + $cookie['secure'], + $cookie['httponly'], + $cookie['samesite'] + ); + } + + $headers = $from->getHeaders(); + $fallbackCookies = $headers['X-Fallback-Cookies'] ?? null; + if ($fallbackCookies === null) { + return; + } + + $to->removeHeader('X-Fallback-Cookies'); + foreach ((array) $fallbackCookies as $value) { + $to->addHeader('X-Fallback-Cookies', $value); + } + } + + /** + * Get the current request container. + */ + private static function getResolverContainer(Http $utopia): Container + { + $container = $utopia->getResource('container'); + + if ($container instanceof Container || (\is_object($container) && \method_exists($container, 'get') && \method_exists($container, 'set'))) { + /** @var Container $container */ + return $container; + } + + /** @var callable(): Container $container */ + return $container(); + } + + /** + * Get the request-scoped lock shared by GraphQL resolver coroutines + * for the current HTTP request. + */ + private static function getLock(Http $utopia): ResolverLock + { + $key = \spl_object_hash($utopia); + if (!isset(self::$locks[$key])) { + self::$locks[$key] = new ResolverLock(); + } + + return self::$locks[$key]; + } + /** * Create a resolver for a given API {@see Route}. * @@ -24,34 +94,39 @@ class Resolvers Http $utopia, ?Route $route, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $route, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $path = $route->getPath(); - foreach ($args as $key => $value) { - if (\str_contains($path, '/:' . $key)) { - $path = \str_replace(':' . $key, $value, $path); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($route, $args): void { + $path = $route->getPath(); + foreach ($args as $key => $value) { + if (\str_contains($path, '/:' . $key)) { + $path = \str_replace(':' . $key, $value, $path); + } + } + + $request->setMethod($route->getMethod()); + $request->setURI($path); + + switch ($route->getMethod()) { + case 'GET': + $request->setQueryString($args); + break; + default: + $request->setPayload($args); + break; } } - - $request->setMethod($route->getMethod()); - $request->setURI($path); - - switch ($route->getMethod()) { - case 'GET': - $request->setQueryString($args); - break; - default: - $request->setPayload($args); - break; - } - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + ); + }); } /** @@ -91,18 +166,23 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -122,23 +202,29 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setQueryString($params($databaseId, $collectionId, $args)); + $beforeResolve = function ($payload) { + return $payload['documents']; + }; - $beforeResolve = function ($payload) { - return $payload['documents']; - }; - - self::resolve($utopia, $request, $response, $resolve, $reject, $beforeResolve); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + beforeResolve: $beforeResolve, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setQueryString($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -158,19 +244,24 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('POST'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('POST'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -190,19 +281,24 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('PATCH'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('PATCH'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -220,18 +316,23 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('DELETE'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('DELETE'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -241,7 +342,7 @@ class Resolvers * @param callable $resolve * @param callable $reject * @param callable|null $beforeResolve - * @param callable|null $beforeReject + * @param callable|null $prepareRequest * @return void * @throws Exception */ @@ -252,38 +353,67 @@ class Resolvers callable $resolve, callable $reject, ?callable $beforeResolve = null, - ?callable $beforeReject = null, + ?callable $prepareRequest = null, ): void { - // Drop json content type so post args are used directly - if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { - $request->removeHeader('content-type'); - } + $lock = self::getLock($utopia); - $request = clone $request; - $utopia->setResource('request', static fn () => $request); - $response->setContentType(Response::CONTENT_TYPE_NULL); + $lock->acquire(); + $original = $utopia->getRoute(); try { - $route = $utopia->match($request, fresh: true); + $request = clone $request; - $utopia->execute($route, $request); - } catch (\Throwable $e) { - if ($beforeReject) { - $e = $beforeReject($e); + // Drop json content type so post args are used directly. + if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { + $request->removeHeader('content-type'); } + + if ($prepareRequest) { + $prepareRequest($request); + } + + /** @var Response $resolverResponse */ + $resolverResponse = clone $utopia->getResource('response'); + $container = self::getResolverContainer($utopia); + $container->set('request', static fn () => $request); + $container->set('response', static fn () => $resolverResponse); + $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); + $resolverResponse->setSent(false); + + $route = $utopia->match($request, fresh: true); + $request->setRoute($route); + + $utopia->execute($route, $request, $resolverResponse); + + self::mergeResponseSideEffects($resolverResponse, $response); + + if ($resolverResponse->isSent()) { + $response + ->setStatusCode($resolverResponse->getStatusCode()) + ->setSent(true); + + $resolve(null); + return; + } + + $payload = $resolverResponse->getPayload(); + $statusCode = $resolverResponse->getStatusCode(); + } catch (\Throwable $e) { $reject($e); return; + } finally { + if ($original !== null) { + $utopia->setRoute($original); + } + + $lock->release(); + unset(self::$locks[\spl_object_hash($utopia)]); } - $payload = $response->getPayload(); - - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) { - if ($beforeReject) { - $payload = $beforeReject($payload); - } + if ($statusCode < 200 || $statusCode >= 400) { $reject(new GQLException( message: $payload['message'], - code: $response->getStatusCode() + code: $statusCode )); return; } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index f76b23dd7a..15bd742d1d 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -3,12 +3,13 @@ namespace Appwrite\GraphQL\Types; use GraphQL\Language\AST\Node; +use GraphQL\Language\AST\StringValueNode; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json { - public $name = 'Assoc'; - public $description = 'The `Assoc` scalar type represents associative array values.'; + public string $name = 'Assoc'; + public ?string $description = 'The `Assoc` scalar type represents associative array values.'; public function serialize($value) { @@ -30,6 +31,10 @@ class Assoc extends Json public function parseLiteral(Node $valueNode, ?array $variables = null) { - return \json_decode($valueNode->value, true); + if ($valueNode instanceof StringValueNode) { + return \json_decode($valueNode->value, true); + } + + return parent::parseLiteral($valueNode, $variables); } } diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php index 39fd4e23b3..daa771911b 100644 --- a/src/Appwrite/GraphQL/Types/InputFile.php +++ b/src/Appwrite/GraphQL/Types/InputFile.php @@ -8,8 +8,8 @@ use GraphQL\Type\Definition\ScalarType; class InputFile extends ScalarType { - public $name = 'InputFile'; - public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by + public string $name = 'InputFile'; + public ?string $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).'; public function serialize($value) diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php index 18d27322a1..627b9081d3 100644 --- a/src/Appwrite/GraphQL/Types/Json.php +++ b/src/Appwrite/GraphQL/Types/Json.php @@ -14,8 +14,8 @@ use GraphQL\Type\Definition\ScalarType; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Json extends ScalarType { - public $name = 'Json'; - public $description = 'The `JSON` scalar type represents JSON values as specified by + public string $name = 'Json'; + public ?string $description = 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; public function serialize($value) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7a2b6fe19a..f1d806bcc5 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -20,6 +20,7 @@ class Realtime extends MessagingAdapter * [CONNECTION_ID] -> * 'projectId' -> [PROJECT_ID] * 'roles' -> [ROLE_x, ROLE_Y] + * 'userId' -> [USER_ID] * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] */ public array $connections = []; @@ -67,25 +68,35 @@ class Realtime extends MessagingAdapter * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) * @return void */ - public function subscribe(string $projectId, mixed $identifier, string $subscriptionId, array $roles, array $channels, array $queryGroup = []): void - { + public function subscribe( + string $projectId, + mixed $identifier, + string $subscriptionId, + array $roles, + array $channels, + array $queryGroup = [], + ?string $userId = null + ): void { if (!isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; } $strings = []; - if (empty($queryGroup)) { - $strings[] = Query::select(['*'])->toString(); - } else { - foreach ($queryGroup as $query) { - $strings[] = $query->toString(); - } - } + $data = []; - $data = [ - 'strings' => $strings, - 'compiled' => RuntimeQuery::compile($queryGroup), - ]; + if (!empty($channels)) { + if (empty($queryGroup)) { + $strings[] = Query::select(['*'])->toString(); + } else { + foreach ($queryGroup as $query) { + $strings[] = $query->toString(); + } + } + $data = [ + 'strings' => $strings, + 'compiled' => RuntimeQuery::compile($queryGroup), + ]; + } foreach ($roles as $role) { if (!isset($this->subscriptions[$projectId][$role])) { @@ -103,10 +114,12 @@ class Realtime extends MessagingAdapter } } - // Update connection info + // Keep userId from onOpen/authentication when provided. + // Fallback to existing stored value for subsequent subscribe upserts. $this->connections[$identifier] = [ 'projectId' => $projectId, 'roles' => $roles, + 'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''), 'channels' => $channels ]; } diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index e481eebf6e..a01031de9b 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -93,6 +93,7 @@ abstract class Migration '1.8.0' => 'V23', '1.8.1' => 'V23', '1.9.0' => 'V24', + '1.9.1' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 38c84c4ae1..24cba578a9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -50,6 +50,11 @@ class Create extends Action return UtopiaResponse::MODEL_DOCUMENT_LIST; } + protected function getSupportForEmptyDocument() + { + return false; + } + public function __construct() { $this @@ -139,30 +144,42 @@ class Create extends Action ->inject('eventProcessor') ->callback($this->action(...)); } + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) : $data; + $supportsEmptyDocument = $this->getSupportForEmptyDocument(); + $hasData = !empty($data); + $hasDocuments = !empty($documents); + /** * Determine which internal path to call, single or bulk */ - if (empty($data) && empty($documents)) { + if (!$supportsEmptyDocument && !$hasData && !$hasDocuments) { // No single or bulk documents provided throw new Exception($this->getMissingDataException()); } - if (!empty($data) && !empty($documents)) { + + // When empty documents are supported, an empty payload should still be treated as single create. + if ($supportsEmptyDocument && !$hasData && !$hasDocuments) { + $data = []; + $hasData = true; + } + + if ($hasData && $hasDocuments) { // Both single and bulk documents provided throw new Exception(Exception::GENERAL_BAD_REQUEST, 'You can only send one of the following parameters: data, ' . $this->getSDKGroup()); } - if (!empty($data) && empty($documentId)) { + if ($hasData && empty($documentId)) { // Single document provided without document ID $document = $this->isCollectionsAPI() ? 'Document' : 'Row'; $message = "$document ID is required when creating a single " . strtolower($document) . '.'; throw new Exception($this->getMissingDataException(), $message); } - if (!empty($documents) && !empty($documentId)) { + if ($hasDocuments && !empty($documentId)) { // Bulk documents provided with document ID $documentId = $this->isCollectionsAPI() ? 'documentId' : 'rowId'; throw new Exception( @@ -170,13 +187,13 @@ class Create extends Action "Param \"$documentId\" is not allowed when creating multiple " . $this->getSDKGroup() . ', set "$id" on each instead.' ); } - if (!empty($documents) && !empty($permissions)) { + if ($hasDocuments && !empty($permissions)) { // Bulk documents provided with permissions throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "permissions" is disallowed when creating multiple ' . $this->getSDKGroup() . ', set "$permissions" on each instead'); } - $isBulk = true; - if (!empty($data)) { + $isBulk = $hasDocuments; + if ($hasData) { // Single document provided, convert to single item array // But remember that it was single to respond with a single document $isBulk = false; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index c35eebaea2..aeee280615 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -145,7 +145,11 @@ class XList extends Action $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); $documentsCacheHit = false; - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + try { + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + } catch (\Throwable) { + $cachedDocuments = null; + } if ($cachedDocuments !== null && $cachedDocuments !== false && @@ -157,21 +161,30 @@ class XList extends Action } else { $documents = $find(); - // Convert Document objects to arrays for caching $documentsArray = \array_map(function ($doc) { return $doc->getArrayCopy(); }, $documents); - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + try { + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } catch (\Throwable) { + } } if ($includeTotal) { $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + try { + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + } catch (\Throwable) { + $cachedTotal = null; + } if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($cacheKey, $total, $totalField); + try { + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } catch (\Throwable) { + } } } else { $total = 0; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php index 039a05ff50..532ae826e2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php @@ -34,6 +34,12 @@ class Create extends DocumentCreate return UtopiaResponse::MODEL_DOCUMENT_LIST; } + protected function getSupportForEmptyDocument() + { + return true; + } + + public function __construct() { $this diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php deleted file mode 100644 index cc7fe41555..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php +++ /dev/null @@ -1,59 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') - ->desc('List document logs') - ->groups(['api', 'database']) - ->label('scope', 'documents.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'documentsDB', - group: 'logs', - name: 'listDocumentLogs', - description: '/docs/references/documentsdb/get-document-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON, - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('documentId', '', new UID(), 'Document ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php deleted file mode 100644 index 51695ea165..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php +++ /dev/null @@ -1,58 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs') - ->desc('List collection logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'documentsDB', - group: $this->getSdkGroup(), - name: 'listCollectionLogs', - description: '/docs/references/documentsdb/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) - ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php deleted file mode 100644 index dea9d30119..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php +++ /dev/null @@ -1,59 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') - ->desc('List document logs') - ->groups(['api', 'database']) - ->label('scope', 'documents.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'vectorsDB', - group: 'logs', - name: 'listDocumentLogs', - description: '/docs/references/vectorsdb/get-document-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON, - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('documentId', '', new UID(), 'Document ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php deleted file mode 100644 index cd0e45eb47..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php +++ /dev/null @@ -1,57 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs') - ->desc('List collection logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'vectorsDB', - group: $this->getSdkGroup(), - name: 'listCollectionLogs', - description: '/docs/references/vectorsdb/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php index a1e3538cac..5d41ed3e2b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php @@ -12,7 +12,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\B use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Create as CreateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Delete as DeleteRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Get as GetRow; -use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Logs\XList as ListRowLogs; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Update as UpdateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Upsert as UpsertRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\XList as ListRows; @@ -21,7 +20,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Cre use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Delete as DeleteColumnIndex; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Get as GetColumnIndex; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\XList as ListColumnIndexes; -use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Logs\XList as ListTableLogs; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Update as UpdateTable; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Usage\Get as GetTableUsage; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\XList as ListTables; @@ -69,7 +67,6 @@ class DocumentsDB extends Base $service->addAction(UpdateTable::getName(), new UpdateTable()); $service->addAction(DeleteTable::getName(), new DeleteTable()); $service->addAction(ListTables::getName(), new ListTables()); - $service->addAction(ListTableLogs::getName(), new ListTableLogs()); $service->addAction(GetTableUsage::getName(), new GetTableUsage()); } @@ -92,7 +89,6 @@ class DocumentsDB extends Base $service->addAction(DeleteRow::getName(), new DeleteRow()); $service->addAction(DeleteRows::getName(), new DeleteRows()); $service->addAction(ListRows::getName(), new ListRows()); - $service->addAction(ListRowLogs::getName(), new ListRowLogs()); $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn()); $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn()); } diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php index 5d12b14b1a..fe96d51d20 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php @@ -10,7 +10,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Bul use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Create as CreateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Delete as DeleteDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Get as GetDocument; -use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Logs\XList as ListDocumentLogs; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Update as UpdateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Upsert as UpsertDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\XList as ListDocuments; @@ -19,7 +18,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Creat use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Delete as DeleteIndex; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Get as GetIndex; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\XList as ListIndexes; -use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Logs\XList as ListCollectionLogs; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Update as UpdateCollection; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Usage\Get as GetCollectionUsage; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\XList as ListCollections; @@ -69,7 +67,6 @@ class VectorsDB extends Base $service->addAction(UpdateCollection::getName(), new UpdateCollection()); $service->addAction(DeleteCollection::getName(), new DeleteCollection()); $service->addAction(ListCollections::getName(), new ListCollections()); - $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs()); $service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage()); } @@ -92,7 +89,6 @@ class VectorsDB extends Base $service->addAction(UpdateDocuments::getName(), new UpdateDocuments()); $service->addAction(UpsertDocuments::getName(), new UpsertDocuments()); $service->addAction(DeleteDocuments::getName(), new DeleteDocuments()); - $service->addAction(ListDocumentLogs::getName(), new ListDocumentLogs()); } private function registerTransactionActions(Service $service): void diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 66ed3e0eab..a50e8f8bdf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -650,26 +650,30 @@ class Databases extends Action Document|null $attribute = null, Document|null $index = null, ): void { - $queueForRealtime - ->setProject($project) - ->setSubscribers(['console']) - ->setEvent($event) - ->setParam('databaseId', $database->getId()) - ->setParam('tableId', $collection->getId()) - ->setParam('collectionId', $collection->getId()); + try { + $queueForRealtime + ->setProject($project) + ->setSubscribers(['console']) + ->setEvent($event) + ->setParam('databaseId', $database->getId()) + ->setParam('tableId', $collection->getId()) + ->setParam('collectionId', $collection->getId()); - if (! empty($attribute)) { - $queueForRealtime - ->setParam('columnId', $attribute->getId()) - ->setParam('attributeId', $attribute->getId()) - ->setPayload($attribute->getArrayCopy()); - } - if (! empty($index)) { - $queueForRealtime - ->setParam('indexId', $index->getId()) - ->setPayload($index->getArrayCopy()); + if (! empty($attribute)) { + $queueForRealtime + ->setParam('columnId', $attribute->getId()) + ->setParam('attributeId', $attribute->getId()) + ->setPayload($attribute->getArrayCopy()); + } + if (! empty($index)) { + $queueForRealtime + ->setParam('indexId', $index->getId()) + ->setPayload($index->getArrayCopy()); + } + $queueForRealtime->trigger(); + } finally { + $queueForRealtime->reset(); } - $queueForRealtime->trigger(); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 37292ce984..72474b03f9 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -305,9 +305,7 @@ class Create extends Base if ($async) { if (is_null($scheduledAt)) { - if ($project->getId() != '6862e6a6000cce69f9da') { - $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); - } + $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); $queueForFunctions ->setType('http') ->setExecution($execution) @@ -348,9 +346,7 @@ class Create extends Base ->setAttribute('scheduleInternalId', $schedule->getSequence()) ->setAttribute('scheduledAt', $scheduledAt); - if ($project->getId() != '6862e6a6000cce69f9da') { - $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); - } + $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { @@ -516,9 +512,7 @@ class Create extends Base ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) ; - if ($project->getId() != '6862e6a6000cce69f9da') { - $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); - } + $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } $executionResponse['headers']['x-appwrite-execution-id'] = $execution->getId(); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index c6c4a0b38c..0071b03d2d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -6,9 +6,9 @@ use Ahc\Jwt\JWT; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Usage\Context; @@ -58,7 +58,7 @@ class Builds extends Action ->inject('project') ->inject('dbForPlatform') ->inject('queueForEvents') - ->inject('queueForScreenshots') + ->inject('publisherForScreenshots') ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') @@ -84,7 +84,7 @@ class Builds extends Action Document $project, Database $dbForPlatform, Event $queueForEvents, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -126,7 +126,7 @@ class Builds extends Action $deviceForFunctions, $deviceForSites, $deviceForFiles, - $queueForScreenshots, + $publisherForScreenshots, $queueForWebhooks, $queueForFunctions, $queueForRealtime, @@ -161,7 +161,7 @@ class Builds extends Action Device $deviceForFunctions, Device $deviceForSites, Device $deviceForFiles, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -1120,10 +1120,10 @@ class Builds extends Action /** Screenshot site */ if ($resource->getCollection() === 'sites') { - $queueForScreenshots - ->setDeploymentId($deployment->getId()) - ->setProject($project) - ->trigger(); + $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot( + project: $project, + deploymentId: $deployment->getId(), + )); Console::log('Site screenshot queued'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 065fe477eb..423bf0bd41 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; +use Appwrite\Event\Message\Screenshot; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; @@ -62,9 +63,11 @@ class Screenshots extends Action throw new \Exception('Missing payload'); } + $screenshotMessage = Screenshot::fromArray($payload); + Console::log('Site screenshot started'); - $deploymentId = $payload['deploymentId'] ?? null; + $deploymentId = $screenshotMessage->deploymentId; $deployment = $dbForProject->getDocument('deployments', $deploymentId); if ($deployment->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php index e01e89641d..76c34a0a2a 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Audits; -use Appwrite\Event\Audit; +use Appwrite\Event\Publisher\Audit; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void { $threshold = (int) $threshold; - $size = $queueForAudits->getSize(); + $size = $publisherForAudits->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php index 6724f25094..82c45db172 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Certificate $queueForCertificates, Response $response): void + public function action(int|string $threshold, Certificate $publisherForCertificates, Response $response): void { $threshold = (int) $threshold; - $size = $queueForCertificates->getSize(); + $size = $publisherForCertificates->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index cb3640746f..6d77cc6e16 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -2,19 +2,19 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; -use Appwrite\Event\Audit; use Appwrite\Event\Build; -use Appwrite\Event\Certificate; use Appwrite\Event\Database; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; +use Appwrite\Event\Publisher\Audit; +use Appwrite\Event\Publisher\Certificate; +use Appwrite\Event\Publisher\Migration as MigrationPublisher; +use Appwrite\Event\Publisher\Screenshot; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Event\Screenshot; -use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; @@ -75,17 +75,17 @@ class Get extends Base ->inject('response') ->inject('queueForDatabase') ->inject('queueForDeletes') - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('queueForMails') ->inject('queueForFunctions') - ->inject('queueForStatsResources') + ->inject('publisherForStatsResources') ->inject('publisherForUsage') ->inject('queueForWebhooks') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForBuilds') ->inject('queueForMessaging') - ->inject('queueForMigrations') - ->inject('queueForScreenshots') + ->inject('publisherForMigrations') + ->inject('publisherForScreenshots') ->callback($this->action(...)); } @@ -95,34 +95,34 @@ class Get extends Base Response $response, Database $queueForDatabase, Delete $queueForDeletes, - Audit $queueForAudits, + Audit $publisherForAudits, Mail $queueForMails, Func $queueForFunctions, - StatsResources $queueForStatsResources, + StatsResourcesPublisher $publisherForStatsResources, UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Build $queueForBuilds, Messaging $queueForMessaging, - Migration $queueForMigrations, - Screenshot $queueForScreenshots, + MigrationPublisher $publisherForMigrations, + Screenshot $publisherForScreenshots, ): void { $threshold = (int) $threshold; $queue = match ($name) { System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase, System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes, - System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits, + System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits, System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, - System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $queueForStatsResources, + System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources, System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage, System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, - System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates, + System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates, System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, - System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots, + System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, - System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $queueForMigrations, + System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php index dd05aebc39..0a655662de 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Logs; -use Appwrite\Event\Audit; +use Appwrite\Event\Publisher\Audit; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void { $threshold = (int) $threshold; - $size = $queueForAudits->getSize(); + $size = $publisherForAudits->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php index 4faca7d8a4..70bef3562b 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Migrations; -use Appwrite\Event\Migration; +use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Migration $queueForMigrations, Response $response): void + public function action(int|string $threshold, MigrationPublisher $publisherForMigrations, Response $response): void { $threshold = (int) $threshold; - $size = $queueForMigrations->getSize(); + $size = $publisherForMigrations->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php index 57605298fd..5ab0aa2532 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsResources; -use Appwrite\Event\StatsResources; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForStatsResources') + ->inject('publisherForStatsResources') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, StatsResources $queueForStatsResources, Response $response): void + public function action(int|string $threshold, StatsResourcesPublisher $publisherForStatsResources, Response $response): void { $threshold = (int) $threshold; - $size = $queueForStatsResources->getSize(); + $size = $publisherForStatsResources->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 59d2c1db49..236c091c31 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -62,7 +62,7 @@ class Create extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('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('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -72,13 +72,10 @@ class Create extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -95,7 +92,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 8759faacc1..9193bdbfdf 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -60,7 +60,7 @@ class Update extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('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('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -70,13 +70,10 @@ class Update extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -92,7 +89,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, ]); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 6794901c47..2fca0ace6c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -64,7 +64,7 @@ class Create extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Deprecated: Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->param('type', '', new Text(256), 'Deprecated: Platform type. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('request') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 1e1f1b5ac1..62d209ea25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -56,7 +56,7 @@ class Update extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('response') ->inject('queueForEvents') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php new file mode 100644 index 0000000000..71c20faca7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->httpAlias('/v1/projects/:projectId/api') + ->desc('Update project protocol status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'protocols.[protocolId].update') + ->label('audits.event', 'project.protocols.[protocolId].update') + ->label('audits.resource', 'project.protocols/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateProtocolStatus', + description: <<param('protocolId', '', new WhiteList(array_keys(Config::getParam('protocols')), true), 'Protocol name. Can be one of: ' . \implode(', ', array_keys(Config::getParam('protocols')))) + ->param('enabled', null, new Boolean(), 'Protocol status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $protocolId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $protocols = $project->getAttribute('apis', []); + $protocols[$protocolId] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'apis' => $protocols, + ]))); + + $queueForEvents->setParam('protocolId', $protocolId); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php new file mode 100644 index 0000000000..f3d9654789 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/services/:serviceId/status') + ->httpAlias('/v1/projects/:projectId/service') + ->desc('Update project service status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'services.[serviceId].update') + ->label('audits.event', 'project.services.[serviceId].update') + ->label('audits.resource', 'project.services/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateServiceStatus', + description: <<param('serviceId', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])))) + ->param('enabled', null, new Boolean(), 'Service status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $serviceId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents + ): void { + $services = $project->getAttribute('services', []); + $services[$serviceId] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'services' => $services, + ]))); + + $queueForEvents->setParam('serviceId', $serviceId); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index 7b6dc67b4f..8dbc720045 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -53,7 +53,7 @@ class Create extends Action ) ], )) - ->param('variableId', 'unique()', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.') ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.') ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 9fd8366097..a2c94928e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -40,6 +42,8 @@ class Http extends Service // Project $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); + $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index bfa62ef920..a6a3e44194 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -62,7 +62,7 @@ class Create extends Action ->param('domain', null, new ValidatorDomain(), 'Domain name.') ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('platform') @@ -70,7 +70,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) + public function action(string $domain, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -114,13 +114,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index a61ce80c4b..4a8bd4897e 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -66,7 +66,7 @@ class Create extends Action ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -132,13 +132,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 95c29f48e8..8a265ba5bb 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -69,7 +69,7 @@ class Create extends Action ->param('resourceType', '', new WhiteList(['site', 'function']), 'Type of parent resource.') ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -78,7 +78,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -136,13 +136,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index ba99cefb42..a9dfa93a49 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -66,7 +66,7 @@ class Create extends Action ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -132,13 +132,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php index 8a0d341132..9e81f6ff18 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Verification; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -56,7 +56,7 @@ class Update extends Action )) ->param('ruleId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Rule ID.', false, ['dbForProject']) ->inject('response') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('project') ->inject('dbForPlatform') @@ -67,7 +67,7 @@ class Update extends Action public function action( string $ruleId, Response $response, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Event $queueForEvents, Document $project, Database $dbForPlatform, @@ -110,12 +110,13 @@ class Update extends Action } // Issue a TLS certificate when DNS verification is successful - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->trigger(); + ]), + )); if (!empty($certificate)) { $rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', '')); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index aa7f14d2ed..5b44c61d18 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -79,7 +79,7 @@ class Delete extends Action Device $deviceForFiles, DeleteEvent $queueForDeletes, Authorization $authorization, - User $user + User $user, ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -110,10 +110,17 @@ class Delete extends Action $deviceDeleted = false; if ($file->getAttribute('chunksTotal') !== $file->getAttribute('chunksUploaded')) { - $deviceDeleted = $deviceForFiles->abort( - $file->getAttribute('path'), - ($file->getAttribute('metadata', [])['uploadId'] ?? '') - ); + try { + $deviceDeleted = $deviceForFiles->abort( + $file->getAttribute('path'), + ($file->getAttribute('metadata', [])['uploadId'] ?? '') + ); + } catch (\Exception $e) { + // If the partial upload chunks are already gone from the device + // (e.g. the upload never wrote anything to disk), treat it as deleted + // so the pending file document can still be removed from the database. + $deviceDeleted = true; + } } else { $deviceDeleted = $deviceForFiles->delete($file->getAttribute('path')); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index a5e48be478..f0ee045214 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -15,7 +15,6 @@ use Utopia\Compression\Algorithms\GZIP; use Utopia\Compression\Algorithms\Zstd; use Utopia\Compression\Compression; use Utopia\Config\Config; -use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -26,6 +25,7 @@ use Utopia\Http\Adapter\Swoole\Request; use Utopia\Image\Image; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Span\Span; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Validator\HexColor; @@ -54,6 +54,7 @@ class Get extends Action ->label('cache', true) ->label('cache.resourceType', 'bucket/{request.bucketId}') ->label('cache.resource', 'file/{request.fileId}') + ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output']) ->label('sdk', new Method( namespace: 'storage', group: 'files', @@ -268,7 +269,17 @@ class Get extends Action $totalTime = \microtime(true) - $startTime; - Console::info("File preview rendered,project=" . $project->getId() . ",bucket=" . $bucketId . ",file=" . $file->getId() . ",uri=" . $request->getURI() . ",total=" . $totalTime . ",rendering=" . $renderingTime . ",decryption=" . $decryptionTime . ",decompression=" . $decompressionTime . ",download=" . $downloadTime); + Span::add('storage.file.id', $file->getId()); + Span::add('storage.bucket.id', $bucketId); + Span::add('storage.file.size_bytes', $file->getAttribute('sizeActual')); + if (!empty($type)) { + Span::add('storage.file.extension', $type); + } + Span::add('storage.timing.download_seconds', $downloadTime); + Span::add('storage.timing.decryption_seconds', $decryptionTime); + Span::add('storage.timing.decompression_seconds', $decompressionTime); + Span::add('storage.timing.rendering_seconds', $renderingTime); + Span::add('storage.timing.total_seconds', $totalTime); $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; @@ -277,7 +288,9 @@ class Get extends Action $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); - $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file)); + $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), new Document([ + 'transformedAt' => $file->getAttribute('transformedAt'), + ]))); } } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 3b516c2d60..d055ecb23f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,6 +126,7 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 5dd5c6dcfa..6295fcd03b 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -307,6 +307,7 @@ class Create extends Action ]; } + $output->setAttribute('type', $type); $output->setAttribute('variables', $variables); $response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index 3c716202af..e192dff2a0 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -65,9 +66,10 @@ class Create extends Action ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -85,9 +87,10 @@ class Create extends Action string $name, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -104,10 +107,10 @@ class Create extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index ebe6fa7bcb..a42500ca46 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -72,6 +72,8 @@ class Get extends Action throw new Exception(Exception::WEBHOOK_NOT_FOUND); } + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php similarity index 77% rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php index 51c5bfbaf9..fbff94735e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/webhooks/:webhookId/signature') + ->setHttpPath('/v1/webhooks/:webhookId/secret') ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature') - ->desc('Update webhook signature key') + ->desc('Update webhook secret key') ->groups(['api', 'webhooks']) ->label('scope', 'webhooks.write') ->label('event', 'webhooks.[webhookId].update') @@ -39,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'webhooks', group: null, - name: 'updateSignature', + name: 'updateSecret', description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -62,6 +65,7 @@ class Update extends Action public function action( string $webhookId, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -78,7 +82,7 @@ class Update extends Action } $updates = new Document([ - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), ]); $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index 968c15dae2..e7b516449e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -63,9 +63,9 @@ class Update extends Action ->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -80,9 +80,9 @@ class Update extends Action string $url, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, Response $response, Document $project, QueueEvent $queueForEvents, @@ -102,9 +102,9 @@ class Update extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, 'enabled' => $enabled, ]); @@ -118,6 +118,8 @@ class Update extends Action $queueForEvents->setParam('webhookId', $webhook->getId()); + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 2a4c4f9e59..f0961b541c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -78,6 +78,15 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if ($attribute === 'authUsername') { + $query->setAttribute('httpUser'); + } elseif ($attribute === 'tls') { + $query->setAttribute('security'); + } + } + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); $cursor = Query::getCursorQueries($queries, false); @@ -111,6 +120,10 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + foreach ($webhooks as $webhook) { + $webhook->removeAttribute('signatureKey'); + } + $response->dynamic(new Document([ 'webhooks' => $webhooks, 'total' => $total, diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index 4805de6ebc..0e9c39a762 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook; -use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -26,6 +26,6 @@ class Http extends Service $this->addAction(GetWebhook::getName(), new GetWebhook()); $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); - $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + $this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret()); } } diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index a7d16e0a52..f5502a5986 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use DateTime; use Swoole\Coroutine\Channel; use Swoole\Process; @@ -29,16 +29,16 @@ class Interval extends Action ->desc('Schedules tasks on regular intervals by publishing them to our queues') ->inject('dbForPlatform') ->inject('getProjectDB') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): void + public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): void { Console::title('Interval V1'); Console::success(APP_NAME . ' interval process v1 has started'); - $timers = $this->runTasks($dbForPlatform, $getProjectDB, $queueForCertificates); + $timers = $this->runTasks($dbForPlatform, $getProjectDB, $publisherForCertificates); $chan = new Channel(1); Process::signal(SIGTERM, function () use ($chan) { @@ -52,16 +52,16 @@ class Interval extends Action } } - public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): array + public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): array { $timers = []; $tasks = $this->getTasks(); foreach ($tasks as $task) { - $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $queueForCertificates) { + $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $publisherForCertificates) { $taskName = $task['name']; Span::init("interval.{$taskName}"); try { - $task['callback']($dbForPlatform, $getProjectDB, $queueForCertificates); + $task['callback']($dbForPlatform, $getProjectDB, $publisherForCertificates); } catch (\Exception $e) { Span::error($e); } finally { @@ -80,15 +80,15 @@ class Interval extends Action return [ [ 'name' => 'domainVerification', - "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates) { - $this->verifyDomain($dbForPlatform, $queueForCertificates); + "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates) { + $this->verifyDomain($dbForPlatform, $publisherForCertificates); }, 'interval' => $intervalDomainVerification * 1000, ] ]; } - private function verifyDomain(Database $dbForPlatform, Certificate $queueForCertificates): void + private function verifyDomain(Database $dbForPlatform, Certificate $publisherForCertificates): void { $time = DatabaseDateTime::now(); $fromTime = new DateTime('-3 days'); // Max 3 days old @@ -115,13 +115,17 @@ class Interval extends Action foreach ($rules as $rule) { try { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_DOMAIN_VERIFICATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION, + )); $processed++; } catch (\Throwable $th) { $failed++; diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index c821435786..fe803f1292 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete; +use Appwrite\Event\Publisher\Certificate; use DateInterval; use DateTime; use Utopia\Console; @@ -29,12 +29,12 @@ class Maintenance extends Action ->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.') ->inject('dbForPlatform') ->inject('console') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForDeletes') ->callback($this->action(...)); } - public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void + public function action(string $type, Database $dbForPlatform, Document $console, Certificate $publisherForCertificates, Delete $queueForDeletes): void { Console::title('Maintenance V1'); Console::success(APP_NAME . ' maintenance process v1 has started'); @@ -59,7 +59,7 @@ class Maintenance extends Action $delay = $next->getTimestamp() - $now->getTimestamp(); } - $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { + $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $publisherForCertificates) { $time = DatabaseDateTime::now(); Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds"); @@ -92,7 +92,7 @@ class Maintenance extends Action ->trigger(); $this->notifyDeleteConnections($queueForDeletes); - $this->renewCertificates($dbForPlatform, $queueForCertificates); + $this->renewCertificates($dbForPlatform, $publisherForCertificates); $this->notifyDeleteCache($cacheRetention, $queueForDeletes); $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes); $this->notifyDeleteCSVExports($queueForDeletes); @@ -124,7 +124,7 @@ class Maintenance extends Action ->trigger(); } - private function renewCertificates(Database $dbForPlatform, Certificate $queueForCertificate): void + private function renewCertificates(Database $dbForPlatform, Certificate $publisherForCertificate): void { $time = DatabaseDateTime::now(); @@ -158,13 +158,17 @@ class Maintenance extends Action continue; } - $queueForCertificate - ->setDomain(new Document([ + $publisherForCertificate->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } } diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 4725f4095f..526ea304de 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -622,29 +622,28 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $repo->execute('config', 'advice.defaultBranchName', 'false'); $repo->addRemote('origin', $gitUrl); - // Fetch and checkout base branch (or create if new repo) + // Fetch and checkout the target branch (e.g. dev) if it exists on remote, + // otherwise create it from the base branch (e.g. main). + // We build on top of the existing remote branch so a regular push + // works without force-pushing against protected branches. + $hasBranch = false; try { - $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $gitBranch); + $hasBranch = true; + } catch (\Throwable) { + // Branch doesn't exist on remote yet + } + + if ($hasBranch) { + $repo->execute('checkout', '-f', $gitBranch); + } else { + // Fetch base branch to create the target branch from it try { + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); $repo->execute('checkout', '-f', $repoBranch); } catch (\Throwable) { $repo->execute('checkout', '-b', $repoBranch); } - } catch (\Throwable) { - $repo->execute('checkout', '-b', $repoBranch); - } - - try { - $repo->execute('pull', 'origin', $repoBranch, '--quiet', '--no-tags'); - } catch (\Throwable) { - } - - // Create or checkout dev branch from the base branch - // This ensures dev always starts from the latest base branch, - // avoiding history divergence caused by squash merges. - try { - $repo->execute('checkout', '-B', $gitBranch, $repoBranch); - } catch (\Throwable) { $repo->execute('checkout', '-b', $gitBranch); } @@ -685,7 +684,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND return true; } - $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet'); + $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { Console::warning(" Git push failed: " . $e->getMessage()); return false; diff --git a/src/Appwrite/Platform/Tasks/SSL.php b/src/Appwrite/Platform/Tasks/SSL.php index ef8283f168..cb33836a99 100644 --- a/src/Appwrite/Platform/Tasks/SSL.php +++ b/src/Appwrite/Platform/Tasks/SSL.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Document; @@ -29,11 +29,11 @@ class SSL extends Action ->param('skip-check', 'true', new Boolean(true), 'If DNS and renew check should be skipped. Defaults to true, and when true, all jobs will result in certificate generation attempt.', true) ->inject('console') ->inject('dbForPlatform') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->callback($this->action(...)); } - public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $queueForCertificates): void + public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates): void { $domain = new Domain(!empty($domain) ? $domain : ''); if (!$domain->isKnown() || $domain->isTest()) { @@ -98,12 +98,13 @@ class SSL extends Action Console::info('Updated existing rule ' . $rule->getId() . ' for domain: ' . $domain->get()); } - $queueForCertificates - ->setDomain(new Document([ - 'domain' => $domain->get() - ])) - ->setSkipRenewCheck($skipCheck) - ->trigger(); + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $console, + domain: new Document([ + 'domain' => $domain->get(), + ]), + skipRenewCheck: $skipCheck, + )); Console::success('Scheduled a job to issue a TLS certificate for domain: ' . $domain->get()); } diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..f867884801 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -7,6 +7,8 @@ use Cron\CronExpression; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Span\Span; +use Utopia\System\System; /** * ScheduleFunctions @@ -88,7 +90,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - return; + continue; } $schedule = $this->schedules[$scheduleKey]; @@ -102,8 +104,22 @@ class ScheduleFunctions extends ScheduleBase ->setFunction($schedule['resource']) ->setMethod('POST') ->setPath('/') - ->setProject($schedule['project']) - ->trigger(); + ->setProject($schedule['project']); + + $projectDoc = $schedule['project']; + $functionDoc = $schedule['resource']; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $projectDoc->getId() === $traceProjectId && $functionDoc->getId() === $traceFunctionId) { + Span::init('execution.trace.v1_functions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $projectDoc->getId()); + Span::add('functionId', $functionDoc->getId()); + Span::add('scheduleId', $schedule['$id'] ?? ''); + Span::current()?->finish(); + } + + $queueForFunctions->trigger(); $this->recordEnqueueDelay($delayConfig['nextDate']); } diff --git a/src/Appwrite/Platform/Tasks/StatsResources.php b/src/Appwrite/Platform/Tasks/StatsResources.php index 220e377619..8699d73bbb 100644 --- a/src/Appwrite/Platform/Tasks/StatsResources.php +++ b/src/Appwrite/Platform/Tasks/StatsResources.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\StatsResources as EventStatsResources; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Platform\Action; use Utopia\Console; use Utopia\Database\Database; @@ -43,11 +43,11 @@ class StatsResources extends Action ->desc('Schedules projects for usage count') ->inject('dbForPlatform') ->inject('logError') - ->inject('queueForStatsResources') + ->inject('publisherForStatsResources') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, callable $logError, EventStatsResources $queueForStatsResources): void + public function action(Database $dbForPlatform, callable $logError, StatsResourcesPublisher $publisherForStatsResources): void { $this->logError = $logError; $this->dbForPlatform = $dbForPlatform; @@ -60,7 +60,7 @@ class StatsResources extends Action $interval = (int) System::getEnv('_APP_STATS_RESOURCES_INTERVAL', '3600'); - Console::loop(function () use ($queueForStatsResources) { + Console::loop(function () use ($publisherForStatsResources) { $last24Hours = (new \DateTime())->sub(\DateInterval::createFromDateString('24 hours')); /** @@ -69,10 +69,10 @@ class StatsResources extends Action $this->foreachDocument($this->dbForPlatform, 'projects', [ Query::greaterThanEqual('accessedAt', DateTime::format($last24Hours)), Query::equal('region', [System::getEnv('_APP_REGION', 'default')]) - ], function ($project) use ($queueForStatsResources) { - $queueForStatsResources - ->setProject($project) - ->trigger(); + ], function ($project) use ($publisherForStatsResources) { + $publisherForStatsResources->enqueue(new \Appwrite\Event\Message\StatsResources( + project: $project, + )); Console::success('project: ' . $project->getId() . '(' . $project->getSequence() . ')' . ' queued'); }); }, $interval); diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index 6bcc85bc36..e5a7950945 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\Audit; use Exception; use Throwable; use Utopia\Console; @@ -40,7 +41,6 @@ class Audits extends Action $this ->desc('Audits worker') ->inject('message') - ->inject('project') ->inject('getAudit') ->callback($this->action(...)); @@ -50,14 +50,13 @@ class Audits extends Action /** * @param Message $message - * @param Document $project * @param callable(Document): \Utopia\Audit\Audit $getAudit * @return Commit|NoCommit * @throws Throwable * @throws \Utopia\Database\Exception * @throws Structure */ - public function action(Message $message, Document $project, callable $getAudit): Commit|NoCommit + public function action(Message $message, callable $getAudit): Commit|NoCommit { $payload = $message->getPayload() ?? []; @@ -65,19 +64,21 @@ class Audits extends Action throw new Exception('Missing payload'); } + $auditMessage = Audit::fromArray($payload); + Console::info('Aggregating audit logs'); - $event = $payload['event'] ?? ''; + $event = $auditMessage->event; $auditPayload = ''; - if ($project->getId() === 'console') { - $auditPayload = $payload['payload'] ?? ''; + if ($auditMessage->project->getId() === 'console') { + $auditPayload = $auditMessage->payload; } - $mode = $payload['mode'] ?? ''; - $resource = $payload['resource'] ?? ''; - $userAgent = $payload['userAgent'] ?? ''; - $ip = $payload['ip'] ?? ''; - $user = new Document($payload['user'] ?? []); + $mode = $auditMessage->mode; + $resource = $auditMessage->resource; + $userAgent = $auditMessage->userAgent; + $ip = $auditMessage->ip; + $user = $auditMessage->user; $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $actorUserId = $impersonatorUserId ?: $user->getId(); @@ -126,14 +127,14 @@ class Audits extends Action ]; } - if (isset($this->logs[$project->getSequence()])) { - $this->logs[$project->getSequence()]['logs'][] = $eventData; + if (isset($this->logs[$auditMessage->project->getSequence()])) { + $this->logs[$auditMessage->project->getSequence()]['logs'][] = $eventData; } else { - $this->logs[$project->getSequence()] = [ + $this->logs[$auditMessage->project->getSequence()] = [ 'project' => new Document([ - '$id' => $project->getId(), - '$sequence' => $project->getSequence(), - 'database' => $project->getAttribute('database'), + '$id' => $auditMessage->project->getId(), + '$sequence' => $auditMessage->project->getSequence(), + 'database' => $auditMessage->project->getAttribute('database'), ]), 'logs' => [$eventData] ]; diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 73509819a9..34234971d9 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -3,10 +3,10 @@ namespace Appwrite\Platform\Workers; use Appwrite\Certificates\Adapter as CertificatesAdapter; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception as AppwriteException; @@ -55,7 +55,7 @@ class Certificates extends Action ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('log') ->inject('certificates') ->inject('plan') @@ -71,7 +71,7 @@ class Certificates extends Action * @param Webhook $queueForWebhooks * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param Certificate $queueForCertificates + * @param Certificate $publisherForCertificates * @param Log $log * @param CertificatesAdapter $certificates * @param array $plan @@ -88,7 +88,7 @@ class Certificates extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Log $log, CertificatesAdapter $certificates, array $plan, @@ -100,21 +100,22 @@ class Certificates extends Action throw new Exception('Missing payload'); } - $document = new Document($payload['domain'] ?? []); + $certificateMessage = \Appwrite\Event\Message\Certificate::fromArray($payload); + $document = $certificateMessage->domain; $domain = new Domain($document->getAttribute('domain', '')); $domainType = $document->getAttribute('domainType'); - $skipRenewCheck = $payload['skipRenewCheck'] ?? false; - $validationDomain = $payload['validationDomain'] ?? null; - $action = $payload['action'] ?? Certificate::ACTION_GENERATION; + $skipRenewCheck = $certificateMessage->skipRenewCheck; + $validationDomain = $certificateMessage->validationDomain; + $action = $certificateMessage->action; $log->addTag('domain', $domain->get()); switch ($action) { - case Certificate::ACTION_DOMAIN_VERIFICATION: - $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $queueForCertificates, $log, $authorization, $validationDomain); + case \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION: + $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $publisherForCertificates, $log, $authorization, $validationDomain); break; - case Certificate::ACTION_GENERATION: + case \Appwrite\Event\Certificate::ACTION_GENERATION: $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain); break; @@ -130,7 +131,7 @@ class Certificates extends Action * @param Webhook $queueForWebhooks * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param Certificate $queueForCertificates + * @param Certificate $publisherForCertificates * @param Log $log * @param ValidatorAuthorization $authorization * @param string|null $validationDomain @@ -146,7 +147,7 @@ class Certificates extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Log $log, ValidatorAuthorization $authorization, ?string $validationDomain = null @@ -188,13 +189,17 @@ class Certificates extends Action // Issue a TLS certificate when domain is verified if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); Console::success('Certificate generation triggered successfully.'); } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index c420444112..f4978780a1 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -759,11 +759,19 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { + /** + * Temporary disabling deletes for internal collections + */ + $queries = \array_map( + fn ($id) => Query::notEqual('$id', $id), + $projectCollectionIds + ); + + $queries[] = Query::orderAsc(); + $this->deleteByGroup( Database::METADATA, - [ - Query::orderAsc() - ], + $queries, $dbForProject ); } elseif ($sharedTablesV2) { diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index d874e26267..99e20be035 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -2,11 +2,13 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\Execution; use Exception; use Utopia\Database\Database; -use Utopia\Database\Document; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; +use Utopia\System\System; class Executions extends Action { @@ -32,21 +34,27 @@ class Executions extends Action Message $message, Database $dbForProject, ): void { - $payload = $message->getPayload() ?? []; - - if (empty($payload)) { - throw new Exception('Missing payload'); - } - - $execution = new Document($payload['execution'] ?? []); + $executionMessage = Execution::fromArray($message->getPayload() ?? []); + $execution = $executionMessage->execution; if ($execution->isEmpty()) { throw new Exception('Missing execution'); } - $project = new Document($payload['project'] ?? []); - if ($project->getId() != '6862e6a6000cce69f9da') { - $dbForProject->upsertDocument('executions', $execution); + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $executionMessage->project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.executions_worker_upsert'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $executionMessage->project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('resourceType', $execution->getAttribute('resourceType', '')); + Span::current()?->finish(); } + + $dbForProject->upsertDocument('executions', $execution); } } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index bed28dad1c..0899fbacb4 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -23,6 +23,7 @@ use Utopia\Database\Query; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; use Utopia\System\System; class Functions extends Action @@ -115,6 +116,22 @@ class Functions extends Action $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); + if (empty($events) && !$function->isEmpty()) { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_dequeue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('payloadType', $type); + Span::add('queuePid', $message->getPid()); + Span::add('queueName', $message->getQueue()); + Span::add('messageTimestamp', (string) $message->getTimestamp()); + Span::current()?->finish(); + } + } + if (!empty($events)) { $limit = 100; $sum = 100; @@ -304,6 +321,20 @@ class Functions extends Action 'duration' => 0.0, ]); + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus_fail'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('trigger', $trigger); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), @@ -522,6 +553,18 @@ class Functions extends Action $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_executor'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $executionId); + Span::add('deploymentId', $deployment->getId()); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $executionResponse = $executor->createExecution( projectId: $project->getId(), deploymentId: $deploymentId, @@ -594,6 +637,19 @@ class Functions extends Action $errorCode = $th->getCode(); } finally { /** Persist final execution status and record usage */ + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 43f5c97ba6..118ff7acf9 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Migration; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; @@ -129,7 +130,7 @@ class Migrations extends Action array $plan, Authorization $authorization, ): void { - $payload = $message->getPayload() ?? []; + $migrationMessage = Migration::fromArray($message->getPayload() ?? []); $this->getDatabasesDB = $getDatabasesDB; $this->getProjectDB = $getProjectDB; @@ -137,12 +138,7 @@ class Migrations extends Action $this->deviceForFiles = $deviceForFiles; $this->plan = $plan; - if (empty($payload)) { - throw new Exception('Missing payload'); - } - - $events = $payload['events'] ?? []; - $migration = new Document($payload['migration'] ?? []); + $migration = $migrationMessage->migration; if ($migration->isEmpty()) { throw new \Exception('Migration not found'); @@ -161,11 +157,7 @@ class Migrations extends Action $this->project = $project; $this->logError = $logError; - $platform = $payload['platform'] ?? Config::getParam('platform', []); - - if (!empty($events)) { - return; - } + $platform = $migrationMessage->platform ?: Config::getParam('platform', []); try { $this->processMigration( diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index b2823d3722..db214f5d32 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\StatsResources as StatsResourcesMessage; use Appwrite\Platform\Action; use Exception; use Throwable; @@ -67,8 +68,8 @@ class StatsResources extends Action { $this->logError = $logError; - $payload = $message->getPayload() ?? []; - if (empty($payload)) { + $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []); + if ($statsResources->project->isEmpty()) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Promises/Promise.php b/src/Appwrite/Promises/Promise.php index a58c7c29a8..579969cd7b 100644 --- a/src/Appwrite/Promises/Promise.php +++ b/src/Appwrite/Promises/Promise.php @@ -19,8 +19,7 @@ abstract class Promise return; } $resolve = function ($value) { - $this->setResult($value); - $this->setState(self::STATE_FULFILLED); + $this->setState($this->setResult($value)); }; $reject = function ($value) { $this->setResult($value); @@ -106,6 +105,11 @@ abstract class Promise } $callable = $this->isFulfilled() ? $onFulfilled : $onRejected; if (!\is_callable($callable)) { + if ($this->isRejected()) { + $reject($this->result); + return; + } + $resolve($this->result); return; } @@ -126,30 +130,36 @@ abstract class Promise abstract public static function all(iterable $promises): self; /** - * Set resolved result + * Set the resolved result, adopting nested promises while preserving + * whether the adopted promise fulfilled or rejected. * * @param mixed $value - * @return void + * @return int */ - protected function setResult(mixed $value): void + protected function setResult(mixed $value): int { if (!\is_callable([$value, 'then'])) { $this->result = $value; - return; + return self::STATE_FULFILLED; } - $resolved = false; + $state = self::STATE_PENDING; - $callable = function ($value) use (&$resolved) { - $this->setResult($value); - $resolved = true; - }; + $value->then( + function ($value) use (&$state) { + $state = $this->setResult($value); + }, + function ($value) use (&$state) { + $this->result = $value; + $state = self::STATE_REJECTED; + } + ); - $value->then($callable, $callable); - - while (!$resolved) { + while ($state === self::STATE_PENDING) { usleep(25000); } + + return $state; } /** diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 02fac12a7a..ce1eb97203 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -78,6 +78,18 @@ abstract class Format ], ]; + private const array REQUEST_PARAMETER_OVERRIDES = [ + [ + 'namespace' => 'project', + 'methods' => [ + 'createWebPlatform', + 'updateWebPlatform', + ], + 'parameter' => 'hostname', + 'required' => true, + ], + ]; + protected array $enumBlacklist = []; public function __construct(Container $container, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) @@ -251,6 +263,182 @@ abstract class Format return $contents; } + /** + * @param array $models + * @return array|null + */ + protected function getDiscriminator(array $models, string $refPrefix): ?array + { + if (\count($models) < 2) { + return null; + } + + $candidateKeys = \array_keys($models[0]->conditions); + + foreach (\array_slice($models, 1) as $model) { + $candidateKeys = \array_values(\array_intersect($candidateKeys, \array_keys($model->conditions))); + } + + if (empty($candidateKeys)) { + return null; + } + + foreach ($candidateKeys as $key) { + $mapping = []; + $isValid = true; + + foreach ($models as $model) { + $rules = $model->getRules(); + $condition = $model->conditions[$key] ?? null; + + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + $isValid = false; + break; + } + + if (!\is_array($condition)) { + if (!\is_scalar($condition)) { + $isValid = false; + break; + } + + $values = [$condition]; + } else { + if ($condition === []) { + $isValid = false; + break; + } + + $values = $condition; + $hasInvalidValue = false; + + foreach ($values as $value) { + if (!\is_scalar($value)) { + $hasInvalidValue = true; + break; + } + } + + if ($hasInvalidValue) { + $isValid = false; + break; + } + } + + if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { + $values = \array_values(\array_filter( + $values, + fn (mixed $value) => \in_array($value, $rules[$key]['enum'], true) + )); + } + + if ($values === []) { + $isValid = false; + break; + } + + $ref = $refPrefix . $model->getType(); + + foreach ($values as $value) { + $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + + if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $ref) { + $isValid = false; + break; + } + + $mapping[$mappingKey] = $ref; + } + + if (!$isValid) { + break; + } + } + + if (!$isValid || $mapping === []) { + continue; + } + + return [ + 'propertyName' => $key, + 'mapping' => $mapping, + ]; + } + + // Single-key failed — try compound discriminator + return $this->getCompoundDiscriminator($models, $refPrefix); + } + + /** + * @param array $models + * @return array|null + */ + private function getCompoundDiscriminator(array $models, string $refPrefix): ?array + { + $allKeys = []; + foreach ($models as $model) { + foreach (\array_keys($model->conditions) as $key) { + if (!\in_array($key, $allKeys, true)) { + $allKeys[] = $key; + } + } + } + + if (\count($allKeys) < 2) { + return null; + } + + $primaryKey = $allKeys[0]; + $primaryMapping = []; + $compoundMapping = []; + + foreach ($models as $model) { + $rules = $model->getRules(); + $conditions = []; + + foreach ($model->conditions as $key => $condition) { + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + return null; + } + + if (!\is_scalar($condition)) { + return null; + } + + $conditions[$key] = \is_bool($condition) ? ($condition ? 'true' : 'false') : (string) $condition; + } + + if (empty($conditions)) { + return null; + } + + $ref = $refPrefix . $model->getType(); + $compoundMapping[$ref] = $conditions; + + // Best-effort single-key mapping — last model with this value wins (fallback) + if (isset($conditions[$primaryKey])) { + $primaryMapping[$conditions[$primaryKey]] = $ref; + } + } + + // Verify compound uniqueness + $seen = []; + foreach ($compoundMapping as $conditions) { + $sig = \json_encode($conditions, JSON_THROW_ON_ERROR); + if (isset($seen[$sig])) { + return null; + } + $seen[$sig] = true; + } + + return \array_filter([ + 'propertyName' => $primaryKey, + 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, + 'x-propertyNames' => $allKeys, + 'x-mapping' => $compoundMapping, + ]); + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ @@ -774,8 +962,38 @@ abstract class Format return $values; } + protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array + { + $config = [ + 'required' => !$optional, + 'nullable' => $nullable, + ]; + + foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + if ( + $override['namespace'] !== $service + || !\in_array($method, $override['methods'], true) + || $override['parameter'] !== $param + ) { + continue; + } + + $config['required'] = $override['required'] ?? $config['required']; + $config['nullable'] = $override['nullable'] ?? $config['nullable']; + break; + } + + $config['emitDefault'] = !$config['required'] && !\is_null($default); + + return $config; + } + public function getResponseEnumName(string $model, string $param): ?string { + if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { + return 'PlatformType'; + } + if ($param !== 'status') { return null; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index dffddc1e40..3060a1a2fe 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -316,9 +316,10 @@ class OpenAPI3 extends Format 'description' => $modelDescription, 'content' => [ $produces => [ - 'schema' => [ - 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model) - ], + 'schema' => \array_filter([ + 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), + 'discriminator' => $this->getDiscriminator($model, '#/components/schemas/'), + ]), ], ], ]; @@ -381,14 +382,23 @@ class OpenAPI3 extends Format */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -744,7 +754,7 @@ class OpenAPI3 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['schema']['default'] = $param['default']; } @@ -755,7 +765,7 @@ class OpenAPI3 extends Format $node['in'] = 'query'; $temp['parameters'][] = $node; } else { // Param is in payload - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -792,7 +802,7 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } } @@ -900,18 +910,30 @@ class OpenAPI3 extends Format $rule['type'] = ($rule['type']) ? $rule['type'] : 'none'; if (\is_array($rule['type'])) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + if ($rule['array']) { - $items = [ + $items = \array_filter([ 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), + ]); } else { - $items = [ + $items = \array_filter([ 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), + ]); } } else { $items = [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d95f99bb70..8d47766117 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,11 +322,12 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => [ + 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model) - ], + }, $model), + 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), + ]), ]; } else { // Response definition using one type @@ -383,14 +384,23 @@ class Swagger2 extends Format /** @var Validator $validator */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -711,7 +721,7 @@ class Swagger2 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['default'] = $param['default']; } @@ -729,7 +739,7 @@ class Swagger2 extends Format continue; } - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -755,7 +765,7 @@ class Swagger2 extends Format $body['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($parameter['nullable']) { $body['schema']['properties'][$name]['x-nullable'] = true; } @@ -871,14 +881,27 @@ class Swagger2 extends Format $rule['type'] = ($rule['type']) ?: 'none'; if (\is_array($rule['type'])) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $xDiscriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + if ($rule['array']) { - $items = [ - 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $xDiscriminator, + ]); } else { - $items = [ - 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $xDiscriminator, + ]); } } else { $items = [ diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index fa20bf34ef..07e27f06cb 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -5,6 +5,28 @@ namespace Appwrite\Utopia\Database\Validator\Queries; class Webhooks extends Base { public const ALLOWED_ATTRIBUTES = [ + 'name', + 'url', + 'authUsername', + 'tls', + 'events', + 'enabled', + 'logs', + 'attempts', + ]; + + /** + * Map API attribute names to DB column names. + */ + private const ATTRIBUTE_ALIASES = [ + 'tls' => 'security', + 'authUsername' => 'httpUser', + ]; + + /** + * DB column names used for schema validation. + */ + private const DB_ATTRIBUTES = [ 'name', 'url', 'httpUser', @@ -21,6 +43,26 @@ class Webhooks extends Base */ public function __construct() { - parent::__construct('webhooks', self::ALLOWED_ATTRIBUTES); + parent::__construct('webhooks', self::DB_ATTRIBUTES); + } + + /** + * Convert API attribute names to DB column names in query strings before validation. + */ + public function isValid($value): bool + { + if (\is_array($value)) { + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); + } + } + unset($queryString); + } + + return parent::isValid($value); } } diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index ed602ecdd5..3f1ea794ab 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -234,6 +234,10 @@ class Request extends UtopiaRequest public function cacheIdentifier(): string { $params = $this->getParams(); + $allowedParams = $this->getRoute()?->getLabel('cache.params', null); + if ($allowedParams !== null) { + $params = array_intersect_key($params, array_flip($allowedParams)); + } ksort($params); return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); } diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php new file mode 100644 index 0000000000..4f1e746775 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -0,0 +1,93 @@ +parseUpdateServiceStatus($content); + break; + case 'project.updateProtocolStatus': + $content = $this->parseUpdateProtocolStatus($content); + break; + case 'project.createKey': + case 'project.updateKey': + $content = $this->parseKeyScopes($content); + break; + case 'webhooks.create': + case 'webhooks.update': + $content = $this->parseWebhook($content); + break; + } + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 27cd65f792..8a4b5c8c0b 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -615,6 +615,8 @@ class Response extends SwooleResponse throw new \Exception('Response body is not a valid JSON object.'); } + $this->payload = \is_array($data) ? $data : (array) $data; + $this ->setContentType(Response::CONTENT_TYPE_JSON, self::CHARSET_UTF8) ->send(\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); @@ -628,6 +630,16 @@ class Response extends SwooleResponse return $this->payload; } + /** + * Set the sent flag on the response. Pass false to allow reuse + * (e.g. batched GraphQL queries), true to prevent further writes. + */ + public function setSent(bool $sent): static + { + $this->sent = $sent; + return $this; + } + /** * Function to add a response filter, the order of filters are first in - first out. * diff --git a/src/Appwrite/Utopia/Response/Filters/V22.php b/src/Appwrite/Utopia/Response/Filters/V22.php new file mode 100644 index 0000000000..4e295e43dd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V22.php @@ -0,0 +1,45 @@ + $this->parseProject($content), + Response::MODEL_WEBHOOK => $this->parseWebhook($content), + Response::MODEL_WEBHOOK_LIST => $this->handleList($content, 'webhooks', fn ($item) => $this->parseWebhook($item)), + default => $content, + }; + } + + private function parseProject(array $content): array + { + foreach (['protocolStatusForRest', 'protocolStatusForGraphql', 'protocolStatusForWebsocket'] as $field) { + unset($content[$field]); + } + return $content; + } + + private function parseWebhook(array $content): array + { + $content['security'] = $content['tls'] ?? true; + unset($content['tls']); + + $content['httpUser'] = $content['authUsername'] ?? ''; + unset($content['authUsername']); + + $content['httpPass'] = $content['authPassword'] ?? ''; + unset($content['authPassword']); + + $content['signatureKey'] = $content['secret'] ?? ''; + unset($content['secret']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php index 3e162bb905..a721235f94 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoArgon2 extends Model { + public array $conditions = [ + 'type' => 'argon2', + ]; + public function __construct() { // No options if imported. If hashed by Appwrite, following configuration is available: diff --git a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php index 709dea1a41..ef15e5d50a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoBcrypt extends Model { + public array $conditions = [ + 'type' => 'bcrypt', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php index 509ee70c31..26b2886330 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoMd5 extends Model { + public array $conditions = [ + 'type' => 'md5', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php index f16792086e..7d8400edec 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoPhpass extends Model { + public array $conditions = [ + 'type' => 'phpass', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php index 4dda297d71..043a27166d 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScrypt extends Model { + public array $conditions = [ + 'type' => 'scrypt', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php index 40b9df1dad..24dd41bb77 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScryptModified extends Model { + public array $conditions = [ + 'type' => 'scryptMod', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoSha.php b/src/Appwrite/Utopia/Response/Model/AlgoSha.php index 2a0893adc4..52743ec26a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoSha.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoSha.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoSha extends Model { + public array $conditions = [ + 'type' => 'sha', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php index 007182d1e9..9dfcc795d6 100644 --- a/src/Appwrite/Utopia/Response/Model/Detection.php +++ b/src/Appwrite/Utopia/Response/Model/Detection.php @@ -7,9 +7,16 @@ use Appwrite\Utopia\Response\Model; abstract class Detection extends Model { - public function __construct() + public function __construct(string $type) { $this + ->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => $type, + 'example' => $type, + 'enum' => [$type], + ]) ->addRule('variables', [ 'type' => Response::MODEL_DETECTION_VARIABLE, 'description' => 'Environment variables found in .env files', diff --git a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php index 4cdf37bbcf..00f318ba4a 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php @@ -8,7 +8,11 @@ class DetectionFramework extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'framework', + ]; + + parent::__construct('framework'); $this ->addRule('framework', [ diff --git a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php index 1e63929092..94368f890c 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php @@ -8,7 +8,11 @@ class DetectionRuntime extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'runtime', + ]; + + parent::__construct('runtime'); $this ->addRule('runtime', [ diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa769..4cb038fc37 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -344,6 +344,22 @@ class Project extends Model ]) ; } + + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $name = $api['name'] ?? ''; + $key = $api['key'] ?? ''; + + $this + ->addRule('protocolStatusFor' . ucfirst($key), [ + 'type' => self::TYPE_BOOLEAN, + 'description' => $name . ' protocol status', + 'example' => true, + 'default' => true, + ]) + ; + } } /** @@ -375,6 +391,7 @@ class Project extends Model { $this->expandSmtpFields($document); $this->expandServiceFields($document); + $this->expandApiFields($document); $this->expandAuthFields($document); $this->expandOAuthProviders($document); @@ -419,6 +436,22 @@ class Project extends Model } } + private function expandApiFields(Document $document): void + { + if (!$document->isSet('apis')) { + return; + } + + $values = $document->getAttribute('apis', []); + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $key = $api['key'] ?? ''; + $value = $values[$key] ?? true; + $document->setAttribute('protocolStatusFor' . ucfirst($key), $value); + } + } + private function expandAuthFields(Document $document): void { if (!$document->isSet('auths')) { diff --git a/src/Appwrite/Utopia/Response/Model/Rule.php b/src/Appwrite/Utopia/Response/Model/Rule.php index 86ac6f470e..1ff854e7ce 100644 --- a/src/Appwrite/Utopia/Response/Model/Rule.php +++ b/src/Appwrite/Utopia/Response/Model/Rule.php @@ -66,8 +66,9 @@ class Rule extends Model ]) ->addRule('deploymentResourceType', [ 'type' => self::TYPE_ENUM, + 'required' => false, 'description' => 'Type of deployment. Possible values are "function", "site". Used if rule\'s type is "deployment".', - 'default' => '', + 'default' => null, 'example' => 'function', 'enum' => ['function', 'site'], ]) diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 1ae8d5cb7b..6a0197e4a1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class Webhook extends Model { @@ -50,27 +51,27 @@ class Webhook extends Model ], 'array' => true, ]) - ->addRule('security', [ + ->addRule('tls', [ 'type' => self::TYPE_BOOLEAN, - 'description' => 'Indicated if SSL / TLS Certificate verification is enabled.', + 'description' => 'Indicates if SSL / TLS certificate verification is enabled.', 'default' => true, 'example' => true, ]) - ->addRule('httpUser', [ + ->addRule('authUsername', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication username.', 'default' => '', 'example' => 'username', ]) - ->addRule('httpPass', [ + ->addRule('authPassword', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication password.', 'default' => '', 'example' => 'password', ]) - ->addRule('signatureKey', [ + ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validated incoming', + 'description' => 'Signature key which can be used to validate incoming webhook payloads. Only returned on creation and secret rotation.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) @@ -94,6 +95,23 @@ class Webhook extends Model ]); } + public function filter(Document $document): Document + { + $document->setAttribute('tls', $document->getAttribute('security')); + $document->removeAttribute('security'); + + $document->setAttribute('authUsername', $document->getAttribute('httpUser')); + $document->removeAttribute('httpUser'); + + $document->setAttribute('authPassword', $document->getAttribute('httpPass')); + $document->removeAttribute('httpPass'); + + $document->setAttribute('secret', $document->getAttribute('signatureKey')); + $document->removeAttribute('signatureKey'); + + return $document; + } + /** * Get Name * diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index e6d6996748..148b29c1d1 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -22,7 +22,7 @@ class Comment 'Every Git commit and branch gets its own deployment URL automatically', 'Custom domains work with both CNAME for subdomains and NS records for apex domains', 'HTTPS and SSL certificates are handled automatically for all your Sites', - 'Functions can run for up to 15 minutes before timing out', + 'Function builds can take up to 45 minutes before timing out', 'Schedule functions to run as often as every minute with cron expressions', 'Environment variables can be scoped per function or shared across your project', 'Function scopes give you fine-grained control over API permissions', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 10641019f0..a62a1e8ba3 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -216,7 +216,7 @@ trait ProjectCustom 'users.*' ], 'url' => 'http://request-catcher-webhook:5000/', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(201, $webhook['headers']['status-code']); @@ -243,7 +243,7 @@ trait ProjectCustom 'apiKey' => $key['body']['secret'], 'devKey' => $devKey['body']['secret'], 'webhookId' => $webhook['body']['$id'], - 'signatureKey' => $webhook['body']['signatureKey'], + 'signatureKey' => $webhook['body']['secret'], ]; } diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 78f7798193..9f825c3c89 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -14,7 +14,12 @@ class AccountConsoleClientTest extends Scope use ProjectConsole; use SideClient; - public function testDeleteAccount(): void + /** + * Test that account deletion succeeds even with active team memberships. + * When the user is the sole owner and only member of a team, the team + * should be cleaned up automatically. + */ + public function testDeleteAccountWithMembership(): void { $email = uniqid() . 'user@localhost.test'; $password = 'password'; @@ -46,7 +51,7 @@ class AccountConsoleClientTest extends Scope $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; - // create team + // Create team — user becomes sole owner and only member $team = $this->client->call(Client::METHOD_POST, '/teams', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', @@ -58,7 +63,51 @@ class AccountConsoleClientTest extends Scope ]); $this->assertEquals($team['headers']['status-code'], 201); - $teamId = $team['body']['$id']; + // Account deletion should succeed even with active membership + $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + } + + /** + * Test that account deletion works when the user has no team memberships. + */ + public function testDeleteAccountWithoutMembership(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ 'origin' => 'http://localhost', @@ -67,27 +116,7 @@ class AccountConsoleClientTest extends Scope 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); - $this->assertEquals($response['headers']['status-code'], 400); - - // DELETE TEAM - $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - $this->assertEquals($response['headers']['status-code'], 204); - - $this->assertEventually(function () use ($session) { - $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - - $this->assertEquals(204, $response['headers']['status-code']); - }, 10_000, 500); + $this->assertEquals(204, $response['headers']['status-code']); } public function testSessionAlert(): void diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 951ab179b3..49f0c4c245 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4160,178 +4160,4 @@ class AccountCustomClientTest extends Scope $this->assertEquals(401, $verification3['headers']['status-code']); } - - /** - * Test that a new email/password session is immediately usable even when - * a concurrent request re-populates the user cache between the cache purge - * and session creation. - * - * Regression test for: purging the user cache BEFORE persisting the session - * allows a concurrent request (from a different Swoole worker) to re-cache - * a stale user document that lacks the new session, causing sessionVerify - * to fail with 401 on subsequent requests using the new session. - */ - public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void - { - $projectId = $this->getProject()['$id']; - $endpoint = $this->client->getEndpoint(); - - $email = uniqid('race_', true) . getmypid() . '@localhost.test'; - $password = 'password123!'; - - // Create user - $response = $this->client->call(Client::METHOD_POST, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => 'Race Test User', - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - // Login to get session A - $responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'email' => $email, - 'password' => $password, - ]); - $this->assertEquals(201, $responseA['headers']['status-code']); - $sessionA = $responseA['cookies']['a_session_' . $projectId]; - - // Verify session A works - $verifyA = $this->client->call(Client::METHOD_GET, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $sessionA, - ]); - $this->assertEquals(200, $verifyA['headers']['status-code']); - - /** - * Race condition scenario: - * 1. Start login B via curl_multi (non-blocking) - * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument - * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead) - * 3. THEN add GET requests to curl_multi - these hit different workers and - * re-cache a stale user document (without session B) during the window - * between purgeCachedDocument and createDocument - * 4. After all complete, verify session B is usable - */ - for ($attempt = 0; $attempt < 5; $attempt++) { - $loginCookies = []; - - $multi = curl_multi_init(); - - // Start login B first (alone) - $loginHandle = curl_init("{$endpoint}/account/sessions/email"); - curl_setopt_array($loginHandle, [ - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'origin: http://localhost', - 'content-type: application/json', - "x-appwrite-project: {$projectId}", - ], - CURLOPT_POSTFIELDS => \json_encode([ - 'email' => $email, - 'password' => $password, - ]), - CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) { - if (\stripos($header, 'set-cookie:') === 0) { - $cookiePart = \trim(\substr($header, 11)); - $eqPos = \strpos($cookiePart, '='); - if ($eqPos !== false) { - $name = \substr($cookiePart, 0, $eqPos); - $rest = \substr($cookiePart, $eqPos + 1); - $semiPos = \strpos($rest, ';'); - $loginCookies[$name] = $semiPos !== false - ? \substr($rest, 0, $semiPos) - : $rest; - } - } - return \strlen($header); - }, - ]); - curl_multi_add_handle($multi, $loginHandle); - - // Drive the login transfer forward and wait for the server to start - // processing the login (past hash verification + cache purge). - $deadline = \microtime(true) + 0.15; // 150ms - do { - curl_multi_exec($multi, $active); - curl_multi_select($multi, 0.005); - } while (\microtime(true) < $deadline && $active); - - // NOW add GET requests - they arrive after the cache purge - // but before session creation (which is delayed by the usleep or I/O). - $getHandles = []; - for ($i = 0; $i < 10; $i++) { - $gh = curl_init("{$endpoint}/account"); - curl_setopt_array($gh, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'origin: http://localhost', - 'content-type: application/json', - "x-appwrite-project: {$projectId}", - "cookie: a_session_{$projectId}={$sessionA}", - ], - ]); - curl_multi_add_handle($multi, $gh); - $getHandles[] = $gh; - } - - // Drive all to completion - do { - $status = curl_multi_exec($multi, $active); - if ($active) { - curl_multi_select($multi, 0.05); - } - } while ($active && $status === CURLM_OK); - - $loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE); - - curl_multi_remove_handle($multi, $loginHandle); - curl_close($loginHandle); - foreach ($getHandles as $gh) { - curl_multi_remove_handle($multi, $gh); - curl_close($gh); - } - curl_multi_close($multi); - - $this->assertEquals(201, $loginStatus, 'Login for session B should succeed'); - - $sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null; - $this->assertNotNull($sessionBCookie, 'Session B cookie should be set'); - - // THE CRITICAL CHECK: verify session B is usable immediately - $verifyB = $this->client->call(Client::METHOD_GET, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => "a_session_{$projectId}={$sessionBCookie}", - ]); - - $this->assertEquals( - 200, - $verifyB['headers']['status-code'], - 'Session B must be immediately usable after login. ' - . 'A 401 here means a stale user cache (without the new session) was served. ' - . 'The fix is to create the session document BEFORE purging the user cache.' - ); - - // Clean up session B for next iteration - $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => "a_session_{$projectId}={$sessionBCookie}", - ]); - } - } } diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 2c5e587fc2..f5f1d1864c 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -11539,4 +11539,162 @@ trait DatabasesBase $this->assertEquals('Product B', $rows['body'][$this->getRecordResource()][0]['name']); $this->assertEquals(139.99, $rows['body'][$this->getRecordResource()][0]['price']); } + public function testDocumentWithEmptyPayload(): void + { + $data = $this->setupCollection(); + $databaseId = $data['databaseId']; + $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ]); + if ($this->getSupportForAttributes()) { + $this->assertEquals(400, $document['headers']['status-code']); + } else { + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals($data['moviesId'], $document['body'][$this->getContainerIdResponseKey()]); + $this->assertArrayNotHasKey('$collection', $document['body']); + $this->assertEquals($databaseId, $document['body']['$databaseId']); + $this->assertTrue(array_key_exists('$sequence', $document['body'])); + $this->assertIsString($document['body']['$sequence']); + + $documentId = $document['body']['$id']; + + $fetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()) + ); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEqualsCanonicalizing([ + '$id', + '$databaseId', + '$createdAt', + '$updatedAt', + '$permissions', + '$sequence', + $this->getContainerIdResponseKey(), + ], \array_keys($fetched['body'])); + $this->assertFalse(array_key_exists('$tenant', $fetched['body'])); + + $updated = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'data' => [ + 'status' => 'draft', + ], + ] + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('draft', $updated['body']['status']); + + $refetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()) + ); + + $this->assertEquals(200, $refetched['headers']['status-code']); + $this->assertEquals('draft', $refetched['body']['status']); + } + } + + /** + * API keys may set $createdAt / $updatedAt; invalid strings must return 400, not 500. + * Assertions are HTTP status codes only (no error body matching). + */ + public function testInvalidDate(): void + { + $data = $this->setupAttributes(); + $databaseId = $data['databaseId']; + $invalidDatetime = '1dfs:12:55+sdf:00'; + $validUpdatedAt = '2024-01-01T00:00:00Z'; + + $apiKeyHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + + $documentPayload = [ + 'title' => 'Captain America', + 'releaseYear' => 1944, + 'actors' => [ + 'Chris Evans', + 'Samuel Jackson', + ], + ]; + $permissions = [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ]; + + $invalidCreate = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => \array_merge($documentPayload, ['$updatedAt' => $invalidDatetime]), + 'permissions' => $permissions, + ]); + $this->assertEquals(400, $invalidCreate['headers']['status-code']); + + $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => $documentPayload, + 'permissions' => $permissions, + ]); + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + $this->assertNotEmpty($documentId); + + $invalidPatch = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders, + [ + 'data' => [ + '$updatedAt' => $invalidDatetime, + ], + ] + ); + $this->assertEquals(400, $invalidPatch['headers']['status-code']); + + $updated = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders, + [ + 'data' => [ + '$updatedAt' => $validUpdatedAt, + ], + ] + ); + $this->assertEquals(200, $updated['headers']['status-code']); + + $refetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders + ); + $this->assertEquals(200, $refetched['headers']['status-code']); + } } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index fda5ef377f..505c7f6539 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -78,12 +78,12 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } - public function testCreateKeyWithNullScopes(): void + public function testCreateKeyWithEmptyScopes(): void { $key = $this->createKey( ID::unique(), - 'Null Scopes Key', - null, + 'Empty Scopes Key', + [], ); $this->assertSame(201, $key['headers']['status-code']); @@ -93,6 +93,58 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } + public function testCreateKeyWithNullScopesV22BackwardCompat(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $key = $this->client->call(Client::METHOD_POST, '/project/keys', $headers, [ + 'keyId' => ID::unique(), + 'name' => 'V22 Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testUpdateKeyWithNullScopesV22BackwardCompat(): void + { + $key = $this->createKey( + ID::unique(), + 'V22 Update Compat Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, [ + 'name' => 'V22 Update Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame([], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + public function testCreateKeyWithoutAuthentication(): void { $response = $this->createKey( diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php new file mode 100644 index 0000000000..0187fc8463 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -0,0 +1,261 @@ +updateProtocolStatus($protocol, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + + // Cleanup + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, true); + } + } + + public function testEnableProtocol(): void + { + // Disable first + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, false); + } + + // Re-enable + foreach (self::$protocols as $protocol) { + $response = $this->updateProtocolStatus($protocol, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + } + + public function testDisableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['protocolStatusForRest']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['protocolStatusForRest']); + } + + public function testDisabledRestBlocksClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnabledRestAllowsClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisabledGraphqlBlocksClientRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => '{ localeListCountries { total } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisableOneProtocolDoesNotAffectOther(): void + { + $this->updateProtocolStatus('graphql', false); + + // REST should still work + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisabledRestBlocksAllServiceEndpoints(): void + { + $endpoints = [ + 'account' => '/account', + 'teams' => '/teams', + 'databases' => '/databases', + 'storage' => '/storage/buckets', + 'functions' => '/functions', + 'sites' => '/sites', + 'locale' => '/locale', + 'health' => '/health', + 'users' => '/users', + 'messaging' => '/messaging/providers', + 'migrations' => '/migrations', + ]; + + $this->updateProtocolStatus('rest', false); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Disabled REST protocol should block ' . $service . ' endpoint (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_api_disabled', $response['body']['type'], 'Disabled REST protocol should return general_api_disabled for ' . $service); + } + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testReenabledRestAllowsAllServiceEndpoints(): void + { + $endpoints = [ + 'teams' => '/teams', + 'databases' => '/databases', + 'functions' => '/functions', + 'locale' => '/locale', + ]; + + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Re-enabled REST protocol should not block ' . $service . ' endpoint'); + } + } + + public function testDisabledGraphqlBlocksMutationRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => 'mutation { teamsCreate(teamId: "unique()", name: "Test") { _id } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testResponseModel(): void + { + $response = $this->updateProtocolStatus('rest', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('protocolStatusForRest', $response['body']); + $this->assertArrayHasKey('protocolStatusForGraphql', $response['body']); + $this->assertArrayHasKey('protocolStatusForWebsocket', $response['body']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + // Failure flow + + public function testUpdateProtocolWithoutAuthentication(): void + { + $response = $this->updateProtocolStatus('rest', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateProtocolInvalidProtocolId(): void + { + $response = $this->updateProtocolStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateProtocolEmptyProtocolId(): void + { + $response = $this->updateProtocolStatus('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + // Helpers + + protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php new file mode 100644 index 0000000000..b2cec9a438 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php @@ -0,0 +1,14 @@ +updateServiceStatus($service, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + + // Cleanup + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, true); + } + } + + public function testEnableService(): void + { + // Disable first + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, false); + } + + // Re-enable + foreach (self::$optionalServices as $service) { + $response = $this->updateServiceStatus($service, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + } + + public function testDisableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['serviceStatusForTeams']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['serviceStatusForTeams']); + } + + public function testDisabledServiceBlocksClientRequest(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/teams', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_service_disabled', $response['body']['type']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnabledServiceAllowsClientRequest(): void + { + $this->updateServiceStatus('teams', false); + $this->updateServiceStatus('teams', true); + + $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisableOneServiceDoesNotAffectOther(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEachDisabledServiceBlocksItsEndpoint(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Service ' . $service . ' should block requests when disabled (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_service_disabled', $response['body']['type'], 'Service ' . $service . ' should return general_service_disabled error type'); + + // Cleanup + $this->updateServiceStatus($service, true); + } + } + + public function testEachReenabledServiceAllowsRequest(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + $this->updateServiceStatus($service, true); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Service ' . $service . ' should allow requests after re-enabling'); + } + } + + public function testResponseModel(): void + { + $response = $this->updateServiceStatus('teams', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('serviceStatusForTeams', $response['body']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + // Failure flow + + public function testUpdateServiceWithoutAuthentication(): void + { + $response = $this->updateServiceStatus('teams', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateServiceInvalidServiceId(): void + { + $response = $this->updateServiceStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateServiceEmptyServiceId(): void + { + $response = $this->updateServiceStatus('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + // Helpers + + protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ServicesConsoleClientTest.php b/tests/e2e/Services/Project/ServicesConsoleClientTest.php new file mode 100644 index 0000000000..b5660607f4 --- /dev/null +++ b/tests/e2e/Services/Project/ServicesConsoleClientTest.php @@ -0,0 +1,14 @@ + 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); self::$cachedProjectWithWebhook = array_merge($projectData, [ 'webhookId' => $response['body']['$id'], - 'signatureKey' => $response['body']['signatureKey'] + 'signatureKey' => $response['body']['secret'] ]); return self::$cachedProjectWithWebhook; @@ -388,6 +388,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 0bb78e7cb8..7b9848e38f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2497,49 +2497,222 @@ class ProjectsConsoleClientTest extends Scope $id = $project['body']['$id']; + // Bulk disable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ])); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $matches = []; - $pattern = '/serviceStatusFor.*/'; - - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } - - foreach ($matches as $value) { - $this->assertFalse($value); - } + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + // Bulk enable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, ]); + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApisAll(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + + // Bulk disable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => false, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + + // Bulk enable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => true, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApiStatus(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + $protocols = ['rest', 'graphql', 'websocket']; + + /** + * Test for Disabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(false, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + + /** + * Test for Enabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(true, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + } + + public function testUpdateProjectApiStatusRealtimeBackwardsCompat(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + + $id = $project['body']['$id']; + + /** + * Test that "realtime" gets renamed to "websocket" via request filter + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', @@ -2548,17 +2721,29 @@ class ProjectsConsoleClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(false, $response['body']['protocolStatusForWebsocket']); - $matches = []; - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } + // Re-enable via old "realtime" name + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => true, + ]); - foreach ($matches as $value) { - $this->assertTrue($value); - } + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(true, $response['body']['protocolStatusForWebsocket']); } public function testUpdateProjectServiceStatusAdmin(): array @@ -2604,6 +2789,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2649,6 +2835,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $key, 'status' => true, @@ -2678,6 +2865,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2725,6 +2913,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2752,6 +2941,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2841,6 +3031,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2862,9 +3053,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -2873,9 +3064,9 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(true, $response['body']['security']); - $this->assertEquals('username', $response['body']['httpUser']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(true, $response['body']['tls']); + $this->assertEquals('username', $response['body']['authUsername']); /** * Test for FAILURE @@ -2889,9 +3080,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['account.unknown', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -2949,8 +3140,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertEquals('username', $response['body']['httpUser']); - $this->assertEquals('password', $response['body']['httpPass']); + $this->assertEquals('username', $response['body']['authUsername']); + $this->assertEquals('password', $response['body']['authPassword']); /** * Test for FAILURE @@ -2978,7 +3169,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -2990,10 +3181,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', @@ -3010,10 +3201,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); /** * Test for FAILURE @@ -3026,7 +3217,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.unknown'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3039,7 +3230,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3064,15 +3255,15 @@ class ProjectsConsoleClientTest extends Scope $webhookId = $data['webhookId']; $signatureKey = $data['signatureKey']; - $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, 'x-appwrite-mode' => 'admin' ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['signatureKey']); - $this->assertNotEquals($signatureKey, $response['body']['signatureKey']); + $this->assertNotEmpty($response['body']['secret']); + $this->assertNotEquals($signatureKey, $response['body']['secret']); } public function testDeleteProjectWebhook(): void @@ -3091,9 +3282,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook To Delete', 'events' => ['users.*.create'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -4771,22 +4962,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('APP_TEST_CREATE_1', $variable['body']['key']); $this->assertEmpty($variable['body']['value']); - // test for variable without variableId (auto-generated) - $variable = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $data['projectId'], - 'x-appwrite-mode' => 'admin', - ], $this->getHeaders()), [ - 'key' => 'APP_TEST_CREATE_AUTO_ID', - 'value' => 'AUTOIDVALUE', - 'secret' => false - ]); - - $this->assertEquals(201, $variable['headers']['status-code']); - $this->assertNotEmpty($variable['body']['$id']); - $this->assertEquals('APP_TEST_CREATE_AUTO_ID', $variable['body']['key']); - $this->assertEquals('AUTOIDVALUE', $variable['body']['value']); - /** * Test for FAILURE */ diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index 95f3665e4c..b2d17c1e4a 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -101,18 +101,14 @@ trait RealtimeBase $client->close(); } - public function testConnectionFailureMissingChannels(): void + public function testConnectionSuccessMissingChannels(): void { $client = $this->getWebsocket([]); $payload = json_decode($client->receive(), true); $this->assertArrayHasKey("type", $payload); $this->assertArrayHasKey("data", $payload); - $this->assertEquals("error", $payload["type"]); - $this->assertEquals(1008, $payload["data"]["code"]); - $this->assertEquals("Missing channels", $payload["data"]["message"]); - \usleep(250000); // 250ms - $this->expectException(ConnectionException::class); // Check if server disconnected client + $this->assertEquals("connected", $payload["type"]); $client->close(); } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 30cc70e981..ff5e981de2 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2,17 +2,11 @@ namespace Tests\E2E\Services\Realtime; -use CURLFile; -use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; use Tests\E2E\Services\Functions\FunctionsBase; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Query; -use WebSocket\TimeoutException; class RealtimeCustomClientQueryTest extends Scope { @@ -20,1704 +14,12 @@ class RealtimeCustomClientQueryTest extends Scope use RealtimeBase; use ProjectCustom; use SideClient; + use RealtimeQueryBase; - public function testAccountChannelWithQuery() + protected function supportForCheckConnectionStatus(): bool { - $user = $this->getUser(); - $userId = $user['$id'] ?? ''; - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe with query that matches current user - $client = $this->getWebsocket(['account'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$userId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Update account name - should receive event (matches query) - $name = "Test User " . uniqid(); - $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]), [ - 'name' => $name - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($name, $event['data']['payload']['name']); - - $client->close(); - - - $user = $this->getUser(); - $userId = $user['$id'] ?? ''; - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe with query that does NOT match current user - $client = $this->getWebsocket(['account'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::notEqual('$id', [$userId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Update account name - should NOT receive event (doesn't match query) - $name = "Test User " . uniqid(); - $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]), [ - 'name' => $name - ]); - - // Should timeout - no event should be received - try { - $data = $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); + return true; } - - public function testDatabaseChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Query Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocumentId = ID::unique(); - - // Subscribe with query for specific document ID - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with matching ID - should receive event - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); - - // Create document with different ID - should NOT receive event - $otherDocumentId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $otherDocumentId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'NotEqual Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $excludedDocumentId = ID::unique(); - - // Subscribe with query that excludes specific document ID - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::notEqual('$id', [$excludedDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with different ID - should receive event - $allowedDocumentId = ID::unique(); - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $allowedDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($allowedDocumentId, $event['data']['payload']['$id']); - - // Create document with excluded ID - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $excludedDocumentId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'GreaterThan Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'score', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for score > 50 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::greaterThan('score', 50)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with score > 50 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'score' => 75 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(75, $event['data']['payload']['score']); - - // Create document with score <= 50 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'score' => 30 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'LesserThan Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'age', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/age', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for age < 18 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::lessThan('age', 18)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with age < 18 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'age' => 15 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(15, $event['data']['payload']['age']); - - // Create document with age >= 18 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'age' => 25 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'GreaterEqual Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for priority >= 5 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::greaterThanEqual('priority', 5)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with priority = 5 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'priority' => 5 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(5, $event['data']['payload']['priority']); - - // Create document with priority > 5 - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'priority' => 8 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(8, $event['data']['payload']['priority']); - - // Create document with priority < 5 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'priority' => 3 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'LesserEqual Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'level', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/level', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for level <= 10 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::lessThanEqual('level', 10)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with level = 10 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'level' => 10 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(10, $event['data']['payload']['level']); - - // Create document with level < 10 - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'level' => 7 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(7, $event['data']['payload']['level']); - - // Create document with level > 10 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'level' => 15 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'IsNull Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'description', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/description', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for description IS NULL - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::isNull('description')->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document without description - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'description' => null - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - - // Create document with description - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'description' => 'Has description' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'IsNotNull Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'email', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/email', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for email IS NOT NULL - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::isNotNull('email')->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with email - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'email' => 'test@example.com' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('test@example.com', $event['data']['payload']['email']); - - // Create document without email - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'And Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with AND query: status = 'active' AND priority > 5 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::equal('status', ['active']), - Query::greaterThan('priority', 5) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document matching both conditions - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'status' => 'active', - 'priority' => 8 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('active', $event['data']['payload']['status']); - $this->assertEquals(8, $event['data']['payload']['priority']); - - // Create document with status = 'active' but priority <= 5 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'status' => 'active', - 'priority' => 3 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create document with priority > 5 but status != 'active' - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'status' => 'inactive', - 'priority' => 9 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Or Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'type', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/type', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with OR query: type = 'urgent' OR type = 'critical' - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::or([ - Query::equal('type', ['urgent']), - Query::equal('type', ['critical']) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with type = 'urgent' - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'type' => 'urgent' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('urgent', $event['data']['payload']['type']); - - // Create document with type = 'critical' - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'type' => 'critical' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('critical', $event['data']['payload']['type']); - - // Create document with type = 'normal' - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'type' => 'normal' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Complex Query Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'score', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with complex query: (category = 'premium' OR category = 'vip') AND score >= 80 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::or([ - Query::equal('category', ['premium']), - Query::equal('category', ['vip']) - ]), - Query::greaterThanEqual('score', 80) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with category = 'premium' and score >= 80 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'premium', - 'score' => 85 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('premium', $event['data']['payload']['category']); - $this->assertEquals(85, $event['data']['payload']['score']); - - // Create document with category = 'vip' and score >= 80 - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'vip', - 'score' => 90 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('vip', $event['data']['payload']['category']); - $this->assertEquals(90, $event['data']['payload']['score']); - - // Create document with category = 'premium' but score < 80 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'premium', - 'score' => 70 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'standard', - 'score' => 85 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testCollectionScopedDocumentsChannelReceivesEvents() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Scoped 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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Scoped Channel Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe only to the fully-qualified documents channel for this collection - $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; - $client = $this->getWebsocket([$scopedChannel], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains($scopedChannel, $response['data']['channels']); - - // Create document in that collection - should receive event on the scoped channel - $documentId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $documentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($documentId, $event['data']['payload']['$id']); - - $client->close(); - } - - public function testCollectionScopedDocumentsChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Scoped Channel Query DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Scoped Channel Query Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocumentId = ID::unique(); - - // Subscribe with query for specific document ID on the fully-qualified documents channel - $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; - $client = $this->getWebsocket([$scopedChannel], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains($scopedChannel, $response['data']['channels']); - - // Create document with matching ID - should receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); - - // Create document with different ID - should NOT receive event - $otherDocumentId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $otherDocumentId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testFilesChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Create bucket - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'bucketId' => ID::unique(), - 'name' => 'Query Test Bucket', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ] - ]); - $bucketId = $bucket['body']['$id']; - - $targetFileId = ID::unique(); - - // Subscribe with query for specific file ID - $client = $this->getWebsocket(['files'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetFileId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create file with matching ID - should receive event - $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'fileId' => $targetFileId, - 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetFileId, $event['data']['payload']['$id']); - - // Create file with different ID - should NOT receive event - $otherFileId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'fileId' => $otherFileId, - 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo2.png'), - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testMultipleQueriesWithAndLogic() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Multiple Queries Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocId = ID::unique(); - - // Subscribe with multiple 'queries' (AND logic - ALL 'queries' must match for event to be received) - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocId])->toString(), - Query::equal('status', ['active'])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document matching BOTH 'queries' - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocId, $event['data']['payload']['$id']); - $this->assertEquals('active', $event['data']['payload']['status']); - - // Create document matching NEITHER query - should not receive event - // keeping it here as below are the documents created with status=>active - // so it will also receive it but the querykey can be used to distinction - $anotherDocId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $anotherDocId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create document with matching ID but wrong status - should NOT receive event (only one query matches) - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - public function testInvalidQueryShouldNotSubscribe() { $user = $this->getUser(); @@ -1812,667 +114,6 @@ class RealtimeCustomClientQueryTest extends Scope ); } - public function testQueryKeys() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Query Keys Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Query Keys Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - // Attributes used by 'queries' - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $queryStatusActive = Query::equal('status', ['active'])->toString(); - $queryStatusPending = Query::equal('status', ['pending'])->toString(); - $queryComplex = Query::and([ - Query::equal('status', ['active']), - Query::equal('category', ['gold']), - ])->toString(); - - // Subscribe with no 'queries' -> should receive all events (has select("*") subscription) - $clientAll = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]); - - // Subscribe with query1 (status == active) - $clientQ1 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusActive, - ]); - - // Subscribe with query2 (status == pending) - $clientQ2 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusPending, - ]); - - // Subscribe with complex query (status == active AND category == gold) - $clientComplex = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryComplex, - ]); - - // All clients should be connected - foreach ([$clientAll, $clientQ1, $clientQ2, $clientComplex] as $client) { - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - } - - // 1) Create active/gold document -> should match Q1 and complex, and be seen by all - $docActiveGoldId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docActiveGoldId, - 'data' => [ - 'status' => 'active', - 'category' => 'gold', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) - $eventAll = json_decode($clientAll->receive(), true); - $this->assertEquals('event', $eventAll['type']); - $this->assertEquals($docActiveGoldId, $eventAll['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventAll['data']); - $this->assertIsArray($eventAll['data']['subscriptions']); - // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty - $this->assertNotEmpty($eventAll['data']['subscriptions']); - - // clientQ1: should receive event, subscriptions should not be empty (query matched) - $eventQ1 = json_decode($clientQ1->receive(), true); - $this->assertEquals('event', $eventQ1['type']); - $this->assertEquals($docActiveGoldId, $eventQ1['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ1['data']); - $this->assertIsArray($eventQ1['data']['subscriptions']); - // clientQ1 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ1['data']['subscriptions']); - - // clientQ2: should NOT receive event (status is active, not pending) - try { - $clientQ2->receive(); - $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // clientComplex: should receive event, subscriptions should not be empty (query matched) - $eventComplex = json_decode($clientComplex->receive(), true); - $this->assertEquals('event', $eventComplex['type']); - $this->assertEquals($docActiveGoldId, $eventComplex['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventComplex['data']); - $this->assertIsArray($eventComplex['data']['subscriptions']); - // clientComplex has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventComplex['data']['subscriptions']); - - // 2) Create pending/silver document -> should match Q2 only, and be seen by all - $docPendingSilverId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docPendingSilverId, - 'data' => [ - 'status' => 'pending', - 'category' => 'silver', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) - $eventAll2 = json_decode($clientAll->receive(), true); - $this->assertEquals('event', $eventAll2['type']); - $this->assertEquals($docPendingSilverId, $eventAll2['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventAll2['data']); - $this->assertIsArray($eventAll2['data']['subscriptions']); - // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty - $this->assertNotEmpty($eventAll2['data']['subscriptions']); - - // clientQ1: should NOT receive event (status is pending) - try { - $clientQ1->receive(); - $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // clientQ2: should receive event, subscriptions should not be empty (query matched) - $eventQ2 = json_decode($clientQ2->receive(), true); - $this->assertEquals('event', $eventQ2['type']); - $this->assertEquals($docPendingSilverId, $eventQ2['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ2['data']); - $this->assertIsArray($eventQ2['data']['subscriptions']); - // clientQ2 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ2['data']['subscriptions']); - - // clientComplex: should NOT receive event (status is pending, category silver) - try { - $clientComplex->receive(); - $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $clientAll->close(); - $clientQ1->close(); - $clientQ2->close(); - $clientComplex->close(); - } - - /** - * Ensure two separate subscriptions with different query keys - * only see their own matching events and expose the correct - * queryKey in queryKeys. - */ - public function testMultipleSubscriptionsDifferentQueryKeys() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Multiple Query Keys Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Multiple Query Keys Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - // Attribute used by 'queries' - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $queryStatusActive = Query::equal('status', ['active'])->toString(); - $queryStatusPending = Query::equal('status', ['pending'])->toString(); - - // Two subscriptions on the same channel with different query keys - $clientQ1 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusActive, - ]); - - $clientQ2 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusPending, - ]); - - // Both should connect - $response = json_decode($clientQ1->receive(), true); - $this->assertEquals('connected', $response['type']); - $response = json_decode($clientQ2->receive(), true); - $this->assertEquals('connected', $response['type']); - - // 1) active document -> only queryStatusActive subscription should see it - $docActiveId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docActiveId, - 'data' => [ - 'status' => 'active', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $eventQ1 = json_decode($clientQ1->receive(), true); - $this->assertEquals('event', $eventQ1['type']); - $this->assertEquals($docActiveId, $eventQ1['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ1['data']); - $this->assertIsArray($eventQ1['data']['subscriptions']); - // clientQ1 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ1['data']['subscriptions']); - - try { - $clientQ2->receive(); - $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // 2) pending document -> only queryStatusPending subscription should see it - $docPendingId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docPendingId, - 'data' => [ - 'status' => 'pending', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $eventQ2 = json_decode($clientQ2->receive(), true); - $this->assertEquals('event', $eventQ2['type']); - $this->assertEquals($docPendingId, $eventQ2['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ2['data']); - $this->assertIsArray($eventQ2['data']['subscriptions']); - // clientQ2 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ2['data']['subscriptions']); - - try { - $clientQ1->receive(); - $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $clientQ1->close(); - $clientQ2->close(); - } - - public function testSubscriptionPreservedAfterPermissionChange() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - $userId = $user['$id'] ?? ''; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Permission Change Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Permission Change Collection', - 'permissions' => [ - Permission::create(Role::user($userId)), - Permission::read(Role::user($userId)), - ], - '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' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocumentId = ID::unique(); - - // Subscribe with query for specific document ID - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - - // Store the original subscription mapping (index => subscriptionId) - $originalSubscriptionMapping = $response['data']['subscriptions']; - $this->assertNotEmpty($originalSubscriptionMapping); - // Get the first subscription ID and its index - $originalIndex = array_key_first($originalSubscriptionMapping); - $originalSubscriptionId = $originalSubscriptionMapping[$originalIndex]; - - // Create document with matching ID - should receive event - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $event['data']); - $this->assertContains($originalSubscriptionId, $event['data']['subscriptions']); - - // Trigger permission change by creating a team owned by a DIFFERENT user, - $teamOwnerEmail = uniqid() . 'owner@localhost.test'; - $teamOwnerPassword = 'password'; - - $teamOwner = $this->client->call(Client::METHOD_POST, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'userId' => ID::unique(), - 'email' => $teamOwnerEmail, - 'password' => $teamOwnerPassword, - 'name' => 'Team Owner', - ]); - - $this->assertEquals(201, $teamOwner['headers']['status-code']); - - $teamOwnerSession = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'email' => $teamOwnerEmail, - 'password' => $teamOwnerPassword, - ]); - - $teamOwnerSession = $teamOwnerSession['cookies']['a_session_' . $projectId] ?? ''; - - $team = $this->client->call(Client::METHOD_POST, '/teams', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $teamOwnerSession, - ], [ - 'teamId' => ID::unique(), - 'name' => 'Test Team', - ]); - $teamId = $team['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], [ - 'email' => $user['email'], - 'roles' => ['member'], - 'url' => 'http://localhost', - ]); - - sleep(1); - - // Verify subscription is still working after permission change - $nonMatchingDocumentId = ID::unique(); - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $nonMatchingDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - ], - ]); - - // This document doesn't match the query, so we shouldn't receive it - try { - $data = $client->receive(); - $this->fail('Expected TimeoutException - document does not match query after permission change'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create a NEW document with a different ID - should NOT receive event - $targetDocumentId2 = ID::unique(); - $document3 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId2, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - ], - ]); - - sleep(1); - - // This should NOT receive event because the query is for $targetDocumentId, not $targetDocumentId2 - // This verifies the query is preserved after permission change - try { - $data = $client->receive(); - $this->fail('Expected TimeoutException - new document does not match original query after permission change'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create a document with the ORIGINAL matching ID - should receive event - $document4 = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $targetDocumentId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'data' => [ - 'status' => 'updated-after-permission-change' - ], - ]); - - // Wait a bit for the event to be processed - sleep(1); - - // Verify the event is received with the preserved subscription - $event2 = json_decode($client->receive(), true); - $this->assertEquals('event', $event2['type']); - $this->assertEquals($targetDocumentId, $event2['data']['payload']['$id']); - $this->assertEquals('updated-after-permission-change', $event2['data']['payload']['status']); - $this->assertArrayHasKey('subscriptions', $event2['data']); - $this->assertIsArray($event2['data']['subscriptions']); - $this->assertNotEmpty($event2['data']['subscriptions']); - // Subscription ID should remain stable after permission change - $this->assertContains($originalSubscriptionId, $event2['data']['subscriptions']); - - $client->close(); - } - - public function testProjectChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Test OLD SDK behavior: project=projectId (string) in query param - // For reserved \"project\" param, string is treated as routing-only (project ID), - // and is not used as queries for the project channel. We should fall back to select(*). - $clientOldSdk = $this->getWebsocket(['project'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], $projectId, null); - - $response = json_decode($clientOldSdk->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - // Should have default select(['*']) subscription since project param was treated as project ID, not queries - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $clientOldSdk->close(); - - // Test NEW SDK behavior: project=Query array in query param, project ID in header - // The reserved param logic should use Query array as subscription queries for project channel - $queryArray = [Query::select(['*'])->toString()]; - $clientNewSdk = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'], - 'project' => [ - 0 => [ - 0 => $queryArray[0] - ] - ] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($clientNewSdk->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - // Should have subscription with the provided query - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $clientNewSdk->close(); - - // Test edge case: project param is array but not a valid Query array - // This should now fail with an invalid query error rather than silently falling back. - $clientEdgeCase = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'], - 'project' => ['invalid', 'array'] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($clientEdgeCase->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('Invalid query', $response['data']['message']); - } - public function testProjectChannelWithHeaderOnly() { $user = $this->getUser(); @@ -2492,13 +133,14 @@ class RealtimeCustomClientQueryTest extends Scope ] ); - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - // Should have default select(['*']) subscription since no project query param - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since no project query param + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } $client->close(); @@ -2520,78 +162,14 @@ class RealtimeCustomClientQueryTest extends Scope ] ); - $response = json_decode($clientWithQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); + $response = $this->assertConnectionStatusIfSupported($clientWithQuery); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } $clientWithQuery->close(); } - - public function testTestsChannelWithQueries() - { - $projectId = 'console'; - - // Subscribe without queries - should receive all events - $clientNoQuery = $this->getWebsocket( - channels: ['tests'], - headers: ['origin' => 'http://localhost'], - projectId: $projectId, - timeout: 5 - ); - - $response = json_decode($clientNoQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Subscribe with matching query - should receive events - $clientWithMatchingQuery = $this->getWebsocket( - channels: ['tests'], - headers: ['origin' => 'http://localhost'], - projectId: $projectId, - queries: [Query::equal('response', ['WS:/v1/realtime:passed'])->toString()], - timeout: 5 - ); - - $response = json_decode($clientWithMatchingQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Subscribe with non-matching query - should NOT receive events - $clientWithNonMatchingQuery = $this->getWebsocket( - channels: ['tests'], - headers: ['origin' => 'http://localhost'], - projectId: $projectId, - queries: [Query::equal('response', ['failed'])->toString()] - ); - - $response = json_decode($clientWithNonMatchingQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - - sleep(2); - - // Client without query should receive event - $eventNoQuery = json_decode($clientNoQuery->receive(), true); - $this->assertEquals('event', $eventNoQuery['type']); - $this->assertEquals('test.event', $eventNoQuery['data']['events'][0]); - $this->assertEquals('WS:/v1/realtime:passed', $eventNoQuery['data']['payload']['response']); - - // Client with matching query should receive event - $eventMatching = json_decode($clientWithMatchingQuery->receive(), true); - $this->assertEquals('event', $eventMatching['type']); - $this->assertEquals('test.event', $eventMatching['data']['events'][0]); - $this->assertEquals('WS:/v1/realtime:passed', $eventMatching['data']['payload']['response']); - - // Client with non-matching query should NOT receive event - try { - $clientWithNonMatchingQuery->receive(); - $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $clientNoQuery->close(); - $clientWithMatchingQuery->close(); - $clientWithNonMatchingQuery->close(); - } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php new file mode 100644 index 0000000000..edce428e0f --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -0,0 +1,521 @@ +getProject()['$id']; + } + + $queryString = \http_build_query([ + 'project' => $projectId, + ]); + + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => $timeout, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + if (empty($channels)) { + return $client; + } + + if ($queries === []) { + $queries = [Query::select(['*'])->toString()]; + } + + $payload = [[ + 'channels' => $channels, + ]]; + + if ($queries !== null) { + $payload[0]['queries'] = $queries; + } + + $existingSubscriptions = $connected['data']['subscriptions'] ?? []; + if (!empty($existingSubscriptions)) { + $payload[0]['subscriptionId'] = $existingSubscriptions[\array_key_first($existingSubscriptions)]; + } + + $client->send(\json_encode([ + 'type' => 'subscribe', + 'data' => $payload, + ])); + + $response = \json_decode($client->receive(), true); + $this->assertEquals('response', $response['type'] ?? null); + $this->assertEquals('subscribe', $response['data']['to'] ?? null); + $this->assertTrue($response['data']['success'] ?? false); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + + return $client; + } + + /** + * Connects (URL has no per-channel queries), then sends a subscribe message with the given query strings. + * Used to assert server rejects unsupported query methods the same way as URL-based subscriptions. + * + * @param array $queryStrings + * @return array + */ + private function receiveSubscribeMessageResponse( + array $channels, + array $headers, + array $queryStrings + ): array { + $projectId = $this->getProject()['$id']; + $queryString = \http_build_query([ + 'project' => $projectId, + ]); + + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 2, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + $client->send(\json_encode([ + 'type' => 'subscribe', + 'data' => [[ + 'channels' => $channels, + 'queries' => $queryStrings, + ]], + ])); + + $response = \json_decode($client->receive(), true); + $client->close(); + + return $response; + } + + private function getWebsocketWithCustomQuery(array $queryParams, array $headers = [], int $timeout = 2): WebSocketClient + { + $queryString = \http_build_query($queryParams); + + return new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => $timeout, + ] + ); + } + + /** + * @param array> $payloadEntries + * @return array + */ + private function sendSubscribeMessage(WebSocketClient $client, array $payloadEntries): array + { + $client->send(\json_encode([ + 'type' => 'subscribe', + 'data' => $payloadEntries, + ])); + $response = \json_decode($client->receive(), true); + $this->assertEquals('response', $response['type'] ?? null); + $this->assertEquals('subscribe', $response['data']['to'] ?? null); + $this->assertTrue($response['data']['success'] ?? false); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + + return $response; + } + + /** + * subscriptionId: update with id from connected, create by omitting id, explicit new id, + * duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all. + */ + public function testSubscribeMessageUpsertCreateAndEdgeCases(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query([ + 'project' => $projectId, + ]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 30, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + $initialResponse = $this->sendSubscribeMessage($client, [[ + 'channels' => ['documents'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $initialSubscriptionId = $initialResponse['data']['subscriptions'][0]['subscriptionId'] ?? ''; + $this->assertNotEmpty($initialSubscriptionId); + + $q1 = [Query::equal('status', ['q1'])->toString()]; + $r1 = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $q1, + ]]); + $this->assertCount(1, $r1['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $r1['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($q1, $r1['data']['subscriptions'][0]['queries']); + + $q2 = [Query::equal('status', ['q2'])->toString()]; + $r2 = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $q2, + ]]); + $this->assertSame($initialSubscriptionId, $r2['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($q2, $r2['data']['subscriptions'][0]['queries']); + + $rOmit = $this->sendSubscribeMessage($client, [[ + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['omitted-slot'])->toString()], + ]]); + $mintedId = $rOmit['data']['subscriptions'][0]['subscriptionId']; + $this->assertNotSame($initialSubscriptionId, $mintedId); + $this->assertNotEmpty($mintedId); + + $explicitNewId = ID::unique(); + $qExplicit = [Query::equal('status', ['explicit'])->toString()]; + $rExplicit = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $explicitNewId, + 'channels' => ['documents'], + 'queries' => $qExplicit, + ]]); + $this->assertSame($explicitNewId, $rExplicit['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($qExplicit, $rExplicit['data']['subscriptions'][0]['queries']); + + $qFirst = [Query::equal('status', ['dup-a'])->toString()]; + $qSecond = [Query::equal('status', ['dup-b'])->toString()]; + $rDup = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $qFirst, + ], + [ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $qSecond, + ], + ]); + $this->assertCount(2, $rDup['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $rDup['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($initialSubscriptionId, $rDup['data']['subscriptions'][1]['subscriptionId']); + $this->assertSame($qSecond, $rDup['data']['subscriptions'][1]['queries']); + + $rMixed = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['mixed-update'])->toString()], + ], + [ + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['mixed-new'])->toString()], + ], + ]); + $this->assertCount(2, $rMixed['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $rMixed['data']['subscriptions'][0]['subscriptionId']); + $mixedSecondId = $rMixed['data']['subscriptions'][1]['subscriptionId']; + $this->assertNotSame($initialSubscriptionId, $mixedSecondId); + $this->assertNotEmpty($mixedSecondId); + + $rSame = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['idempotent'])->toString()], + ]]); + $rSameAgain = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['idempotent'])->toString()], + ]]); + $this->assertSame($rSame['data']['subscriptions'][0]['queries'], $rSameAgain['data']['subscriptions'][0]['queries']); + + $rEmpty = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [], + ]]); + $this->assertCount(1, $rEmpty['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $rEmpty['data']['subscriptions'][0]['subscriptionId']); + + $client->close(); + } + + public function testInvalidQueryShouldNotSubscribe(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + // Test 1: Simple invalid query method (contains is not allowed) + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::contains('status', ['active'])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('contains', $response['data']['message']); + + // Test 2: Invalid query method in nested AND query + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::and([ + Query::equal('status', ['active']), + Query::search('name', 'test'), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('search', $response['data']['message']); + + // Test 3: Invalid query method in nested OR query + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::or([ + Query::equal('status', ['active']), + Query::between('score', 0, 100), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('between', $response['data']['message']); + + // Test 4: Deeply nested invalid query (AND -> OR -> invalid) + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::and([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('score', 50), + Query::startsWith('name', 'test'), + ]), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('startsWith', $response['data']['message']); + + // Test 5: Multiple invalid 'queries' in nested structure + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::and([ + Query::contains('tags', ['important']), + Query::or([ + Query::endsWith('email', '@example.com'), + Query::equal('status', ['active']), + ]), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertTrue( + \str_contains($response['data']['message'], 'contains') || + \str_contains($response['data']['message'], 'endsWith') + ); + } + + public function testProjectChannelWithHeaderOnly(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocketWithCustomQuery( + [ + 'project' => $projectId, + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = \json_decode($client->receive(), true); + $this->assertSame('connected', $response['type']); + $subscribeResponse = $this->sendSubscribeMessage($client, [[ + 'channels' => ['project'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $this->assertCount(1, $subscribeResponse['data']['subscriptions']); + $this->assertSame(['project'], $subscribeResponse['data']['subscriptions'][0]['channels']); + + $client->close(); + + $clientWithQuery = $this->getWebsocketWithCustomQuery( + [ + 'project' => $projectId, + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = \json_decode($clientWithQuery->receive(), true); + $this->assertSame('connected', $response['type']); + $subscribeResponseWithQuery = $this->sendSubscribeMessage($clientWithQuery, [[ + 'channels' => ['project'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $this->assertCount(1, $subscribeResponseWithQuery['data']['subscriptions']); + $this->assertSame(['project'], $subscribeResponseWithQuery['data']['subscriptions'][0]['channels']); + + $clientWithQuery->close(); + } + + public function testQueryMessageFiltersEvents(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $userId = $user['$id'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Message Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Query Message Test Collection', + 'permissions' => [ + Permission::create(Role::user($userId)), + ], + '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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + $otherDocumentId = ID::unique(); + + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + // Create matching document - should receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create non-matching document - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered by updated query'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 9c768f00d1..ca07d45f46 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3295,6 +3295,277 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + public function testChannelMirrorEventsAcrossDatabasesAndTablesdb(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + /** + * Case 1: Trigger event through /databases route and verify both + * legacy collections/documents and tables/rows events are generated. + */ + $legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror Legacy DB', + ]); + $this->assertEquals(201, $legacyDatabase['headers']['status-code']); + $legacyDatabaseId = $legacyDatabase['body']['$id']; + + $legacyCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Legacy Collection', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $legacyCollectionId = $legacyCollection['body']['$id']; + + $attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code']); + + $this->assertEventually(function () use ($legacyDatabaseId, $legacyCollectionId) { + $attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals('available', $attribute['body']['status']); + }, 30000, 250); + + $legacyClient = $this->getWebsocket([ + "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents", + "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows", + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($legacyClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $legacyDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documentId' => $legacyDocumentId, + 'data' => [ + 'name' => 'legacy-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $document['headers']['status-code']); + + $legacyEvent = json_decode($legacyClient->receive(), true); + $this->assertEquals('event', $legacyEvent['type']); + $this->assertContains( + "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents.{$legacyDocumentId}.create", + $legacyEvent['data']['events'] + ); + $this->assertContains( + "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows.{$legacyDocumentId}.create", + $legacyEvent['data']['events'] + ); + $legacyClient->close(); + + /** + * Case 2: Trigger event through /tablesdb route and verify both + * tables/rows and legacy collections/documents events are generated. + */ + $tablesDatabase = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror TablesDB', + ]); + $this->assertEquals(201, $tablesDatabase['headers']['status-code']); + $tablesDatabaseId = $tablesDatabase['body']['$id']; + + $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'tableId' => ID::unique(), + 'name' => 'Mirror Table', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $column['headers']['status-code']); + + $this->assertEventually(function () use ($tablesDatabaseId, $tableId) { + $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals('available', $column['body']['status']); + }, 120000, 500); + + $tablesClient = $this->getWebsocket([ + "databases.{$tablesDatabaseId}.tables.{$tableId}.rows", + "databases.{$tablesDatabaseId}.collections.{$tableId}.documents", + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($tablesClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $rowId = ID::unique(); + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'rowId' => $rowId, + 'data' => [ + 'name' => 'tablesdb-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $tablesEvent = json_decode($tablesClient->receive(), true); + $this->assertEquals('event', $tablesEvent['type']); + $this->assertContains( + "databases.{$tablesDatabaseId}.tables.{$tableId}.rows.{$rowId}.create", + $tablesEvent['data']['events'] + ); + $this->assertContains( + "databases.{$tablesDatabaseId}.collections.{$tableId}.documents.{$rowId}.create", + $tablesEvent['data']['events'] + ); + $tablesClient->close(); + + /** + * Case 3: Trigger event through /documentsdb route and verify only + * documentsdb events are generated (no databases/tablesdb mirrors). + */ + $documentsDatabase = $this->client->call(Client::METHOD_POST, '/documentsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror DocumentsDB', + ]); + $this->assertEquals(201, $documentsDatabase['headers']['status-code']); + $documentsDatabaseId = $documentsDatabase['body']['$id']; + + $documentsCollection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Mirror Documents Collection', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $documentsCollection['headers']['status-code']); + $documentsCollectionId = $documentsCollection['body']['$id']; + + $documentsClient = $this->getWebsocket([ + "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents", + 'documents', + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($documentsClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $documentsDocumentId = ID::unique(); + $documentsDocument = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections/' . $documentsCollectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'documentId' => $documentsDocumentId, + 'data' => [ + 'name' => 'documentsdb-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $documentsDocument['headers']['status-code']); + + $documentsEvent = json_decode($documentsClient->receive(), true); + $this->assertEquals('event', $documentsEvent['type']); + $this->assertContains( + "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents.{$documentsDocumentId}.create", + $documentsEvent['data']['events'] + ); + $this->assertEmpty( + array_filter( + $documentsEvent['data']['events'], + fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.') + ) + ); + $documentsClient->close(); + } + public function testChannelDatabaseTransactionMultipleOperations() { $user = $this->getUser(); @@ -3968,7 +4239,16 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals(256, $name['body']['size']); $this->assertTrue($name['body']['required']); - sleep(2); + $this->assertEventually(function () use ($databaseId, $actorsId) { + $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals(200, $column['headers']['status-code']); + $this->assertEquals('available', $column['body']['status']); + }, 120000, 500); /** * Test Document Create @@ -4683,6 +4963,8 @@ class RealtimeCustomClientTest extends Scope $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.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']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create", $response['data']['events']); + $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.'))); $this->assertNotEmpty($response['data']['payload']); $this->assertEquals('Chris Evans', $response['data']['payload']['name']); @@ -4714,6 +4996,8 @@ class RealtimeCustomClientTest extends Scope $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']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.'))); $this->assertNotEmpty($response['data']['payload']); $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); @@ -5367,8 +5651,6 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('$id', $response['data']['payload']); $this->assertEquals(15, $response['data']['payload']['score']); - sleep(1); - try { $client->receive(); $this->fail('Should not receive duplicate event'); @@ -5400,8 +5682,6 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('$id', $response['data']['payload']); $this->assertEquals(12, $response['data']['payload']['score']); - sleep(1); - try { $client->receive(); $this->fail('Should not receive duplicate event'); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php new file mode 100644 index 0000000000..04ed56dae6 --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -0,0 +1,2449 @@ +supportForCheckConnectionStatus()) { + return null; + } + + $response = json_decode($client->receive(), true); + $this->assertSame('connected', $response['type']); + return $response; + } + + public function testAccountChannelWithQuery() + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that matches current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$userId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Update account name - should receive event (matches query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($name, $event['data']['payload']['name']); + + $client->close(); + + + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that does NOT match current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$userId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Update account name - should NOT receive event (doesn't match query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + // Should timeout - no event should be received + try { + $data = $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testDatabaseChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with matching ID - should receive event + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'NotEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $excludedDocumentId = ID::unique(); + + // Subscribe with query that excludes specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$excludedDocumentId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with different ID - should receive event + $allowedDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $allowedDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($allowedDocumentId, $event['data']['payload']['$id']); + + // Create document with excluded ID - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $excludedDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for score > 50 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThan('score', 50)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with score > 50 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 75 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(75, $event['data']['payload']['score']); + + // Create document with score <= 50 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 30 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/age', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for age < 18 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThan('age', 18)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with age < 18 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(15, $event['data']['payload']['age']); + + // Create document with age >= 18 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 25 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for priority >= 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThanEqual('priority', 5)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with priority = 5 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 5 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(5, $event['data']['payload']['priority']); + + // Create document with priority > 5 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with priority < 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'level', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/level', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for level <= 10 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThanEqual('level', 10)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with level = 10 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 10 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(10, $event['data']['payload']['level']); + + // Create document with level < 10 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 7 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(7, $event['data']['payload']['level']); + + // Create document with level > 10 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'description', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/description', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for description IS NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNull('description')->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document without description - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => null + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + + // Create document with description - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => 'Has description' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNotNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for email IS NOT NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('email')->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with email - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'email' => 'test@example.com' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('test@example.com', $event['data']['payload']['email']); + + // Create document without email - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'And Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with AND query: status = 'active' AND priority > 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('priority', 5) + ])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document matching both conditions - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('active', $event['data']['payload']['status']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with status = 'active' but priority <= 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with priority > 5 but status != 'active' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'inactive', + 'priority' => 9 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Or Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'type', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/type', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with OR query: type = 'urgent' OR type = 'critical' + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('type', ['urgent']), + Query::equal('type', ['critical']) + ])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with type = 'urgent' - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'urgent' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('urgent', $event['data']['payload']['type']); + + // Create document with type = 'critical' - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'critical' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('critical', $event['data']['payload']['type']); + + // Create document with type = 'normal' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'normal' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Complex Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with complex query: (category = 'premium' OR category = 'vip') AND score >= 80 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::or([ + Query::equal('category', ['premium']), + Query::equal('category', ['vip']) + ]), + Query::greaterThanEqual('score', 80) + ])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with category = 'premium' and score >= 80 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('premium', $event['data']['payload']['category']); + $this->assertEquals(85, $event['data']['payload']['score']); + + // Create document with category = 'vip' and score >= 80 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'vip', + 'score' => 90 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('vip', $event['data']['payload']['category']); + $this->assertEquals(90, $event['data']['payload']['score']); + + // Create document with category = 'premium' but score < 80 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 70 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'standard', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testCollectionScopedDocumentsChannelReceivesEvents() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Scoped 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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Scoped Channel Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe only to the fully-qualified documents channel for this collection + $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; + $client = $this->getWebsocket([$scopedChannel], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains($scopedChannel, $response['data']['channels']); + } + + // Create document in that collection - should receive event on the scoped channel + $documentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($documentId, $event['data']['payload']['$id']); + + $client->close(); + } + + public function testCollectionScopedDocumentsChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Scoped Channel Query DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Scoped Channel Query Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID on the fully-qualified documents channel + $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; + $client = $this->getWebsocket([$scopedChannel], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains($scopedChannel, $response['data']['channels']); + } + + // Create document with matching ID - should receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testFilesChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create bucket + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'bucketId' => ID::unique(), + 'name' => 'Query Test Bucket', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ] + ]); + $bucketId = $bucket['body']['$id']; + + $targetFileId = ID::unique(); + + // Subscribe with query for specific file ID + $client = $this->getWebsocket(['files'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetFileId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create file with matching ID - should receive event + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $targetFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetFileId, $event['data']['payload']['$id']); + + // Create file with different ID - should NOT receive event + $otherFileId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $otherFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo2.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testMultipleQueriesWithAndLogic() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Multiple Queries Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocId = ID::unique(); + + // Subscribe with multiple 'queries' (AND logic - ALL 'queries' must match for event to be received) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocId])->toString(), + Query::equal('status', ['active'])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document matching BOTH 'queries' - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocId, $event['data']['payload']['$id']); + $this->assertEquals('active', $event['data']['payload']['status']); + + // Create document matching NEITHER query - should not receive event + // keeping it here as below are the documents created with status=>active + // so it will also be received, but the query key can be used to distinguish it + $anotherDocId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $anotherDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with matching ID but wrong status - should NOT receive event (only one query matches) + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testQueryKeys() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Keys Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Query Keys Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + // Attributes used by 'queries' + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $queryStatusActive = Query::equal('status', ['active'])->toString(); + $queryStatusPending = Query::equal('status', ['pending'])->toString(); + $queryComplex = Query::and([ + Query::equal('status', ['active']), + Query::equal('category', ['gold']), + ])->toString(); + + // Subscribe with no 'queries' -> should receive all events (has select("*") subscription) + $clientAll = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + // Subscribe with query1 (status == active) + $clientQ1 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusActive, + ]); + + // Subscribe with query2 (status == pending) + $clientQ2 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusPending, + ]); + + // Subscribe with complex query (status == active AND category == gold) + $clientComplex = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryComplex, + ]); + + // All clients should be connected + foreach ([$clientAll, $clientQ1, $clientQ2, $clientComplex] as $client) { + $this->assertConnectionStatusIfSupported($client); + } + + // 1) Create active/gold document -> should match Q1 and complex, and be seen by all + $docActiveGoldId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docActiveGoldId, + 'data' => [ + 'status' => 'active', + 'category' => 'gold', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) + $eventAll = json_decode($clientAll->receive(), true); + $this->assertEquals('event', $eventAll['type']); + $this->assertEquals($docActiveGoldId, $eventAll['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventAll['data']); + $this->assertIsArray($eventAll['data']['subscriptions']); + // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty + $this->assertNotEmpty($eventAll['data']['subscriptions']); + + // clientQ1: should receive event, subscriptions should not be empty (query matched) + $eventQ1 = json_decode($clientQ1->receive(), true); + $this->assertEquals('event', $eventQ1['type']); + $this->assertEquals($docActiveGoldId, $eventQ1['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ1['data']); + $this->assertIsArray($eventQ1['data']['subscriptions']); + // clientQ1 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ1['data']['subscriptions']); + + // clientQ2: should NOT receive event (status is active, not pending) + try { + $clientQ2->receive(); + $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // clientComplex: should receive event, subscriptions should not be empty (query matched) + $eventComplex = json_decode($clientComplex->receive(), true); + $this->assertEquals('event', $eventComplex['type']); + $this->assertEquals($docActiveGoldId, $eventComplex['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventComplex['data']); + $this->assertIsArray($eventComplex['data']['subscriptions']); + // clientComplex has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventComplex['data']['subscriptions']); + + // 2) Create pending/silver document -> should match Q2 only, and be seen by all + $docPendingSilverId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docPendingSilverId, + 'data' => [ + 'status' => 'pending', + 'category' => 'silver', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) + $eventAll2 = json_decode($clientAll->receive(), true); + $this->assertEquals('event', $eventAll2['type']); + $this->assertEquals($docPendingSilverId, $eventAll2['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventAll2['data']); + $this->assertIsArray($eventAll2['data']['subscriptions']); + // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty + $this->assertNotEmpty($eventAll2['data']['subscriptions']); + + // clientQ1: should NOT receive event (status is pending) + try { + $clientQ1->receive(); + $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // clientQ2: should receive event, subscriptions should not be empty (query matched) + $eventQ2 = json_decode($clientQ2->receive(), true); + $this->assertEquals('event', $eventQ2['type']); + $this->assertEquals($docPendingSilverId, $eventQ2['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ2['data']); + $this->assertIsArray($eventQ2['data']['subscriptions']); + // clientQ2 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ2['data']['subscriptions']); + + // clientComplex: should NOT receive event (status is pending, category silver) + try { + $clientComplex->receive(); + $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientAll->close(); + $clientQ1->close(); + $clientQ2->close(); + $clientComplex->close(); + } + + /** + * Ensure two separate subscriptions with different query keys + * only see their own matching events and expose the correct + * queryKey in queryKeys. + */ + public function testMultipleSubscriptionsDifferentQueryKeys() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Multiple Query Keys Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Multiple Query Keys Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + // Attribute used by 'queries' + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $queryStatusActive = Query::equal('status', ['active'])->toString(); + $queryStatusPending = Query::equal('status', ['pending'])->toString(); + + // Two subscriptions on the same channel with different query keys + $clientQ1 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusActive, + ]); + + $clientQ2 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusPending, + ]); + + // Both should connect + $this->assertConnectionStatusIfSupported($clientQ1); + $this->assertConnectionStatusIfSupported($clientQ2); + + // 1) active document -> only queryStatusActive subscription should see it + $docActiveId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docActiveId, + 'data' => [ + 'status' => 'active', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $eventQ1 = json_decode($clientQ1->receive(), true); + $this->assertEquals('event', $eventQ1['type']); + $this->assertEquals($docActiveId, $eventQ1['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ1['data']); + $this->assertIsArray($eventQ1['data']['subscriptions']); + // clientQ1 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ1['data']['subscriptions']); + + try { + $clientQ2->receive(); + $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // 2) pending document -> only queryStatusPending subscription should see it + $docPendingId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docPendingId, + 'data' => [ + 'status' => 'pending', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $eventQ2 = json_decode($clientQ2->receive(), true); + $this->assertEquals('event', $eventQ2['type']); + $this->assertEquals($docPendingId, $eventQ2['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ2['data']); + $this->assertIsArray($eventQ2['data']['subscriptions']); + // clientQ2 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ2['data']['subscriptions']); + + try { + $clientQ1->receive(); + $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientQ1->close(); + $clientQ2->close(); + } + + public function testSubscriptionPreservedAfterPermissionChange() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $userId = $user['$id'] ?? ''; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Permission Change Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Permission Change Collection', + 'permissions' => [ + Permission::create(Role::user($userId)), + Permission::read(Role::user($userId)), + ], + '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' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $originalSubscriptionId = null; + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + + // Store the original subscription mapping (index => subscriptionId) + $originalSubscriptionMapping = $response['data']['subscriptions']; + $this->assertNotEmpty($originalSubscriptionMapping); + // Get the first subscription ID and its index + $originalIndex = array_key_first($originalSubscriptionMapping); + $originalSubscriptionId = $originalSubscriptionMapping[$originalIndex]; + } + + // Create document with matching ID - should receive event + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + if ($originalSubscriptionId !== null) { + $this->assertArrayHasKey('subscriptions', $event['data']); + $this->assertContains($originalSubscriptionId, $event['data']['subscriptions']); + } + + // Trigger permission change by creating a team owned by a DIFFERENT user, + $teamOwnerEmail = uniqid() . 'owner@localhost.test'; + $teamOwnerPassword = 'password'; + + $teamOwner = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $teamOwnerEmail, + 'password' => $teamOwnerPassword, + 'name' => 'Team Owner', + ]); + + $this->assertEquals(201, $teamOwner['headers']['status-code']); + + $teamOwnerSession = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $teamOwnerEmail, + 'password' => $teamOwnerPassword, + ]); + + $teamOwnerSession = $teamOwnerSession['cookies']['a_session_' . $projectId] ?? ''; + + $team = $this->client->call(Client::METHOD_POST, '/teams', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $teamOwnerSession, + ], [ + 'teamId' => ID::unique(), + 'name' => 'Test Team', + ]); + $teamId = $team['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'email' => $user['email'], + 'roles' => ['member'], + 'url' => 'http://localhost', + ]); + + sleep(1); + + // Verify subscription is still working after permission change + $nonMatchingDocumentId = ID::unique(); + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $nonMatchingDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + ], + ]); + + // This document doesn't match the query, so we shouldn't receive it + try { + $data = $client->receive(); + $this->fail('Expected TimeoutException - document does not match query after permission change'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create a NEW document with a different ID - should NOT receive event + $targetDocumentId2 = ID::unique(); + $document3 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId2, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + ], + ]); + + sleep(1); + + // This should NOT receive event because the query is for $targetDocumentId, not $targetDocumentId2 + // This verifies the query is preserved after permission change + try { + $data = $client->receive(); + $this->fail('Expected TimeoutException - new document does not match original query after permission change'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create a document with the ORIGINAL matching ID - should receive event + $document4 = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $targetDocumentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => [ + 'status' => 'updated-after-permission-change' + ], + ]); + + // Wait a bit for the event to be processed + sleep(1); + + // Verify the event is received with the preserved subscription + $event2 = json_decode($client->receive(), true); + $this->assertEquals('event', $event2['type']); + $this->assertEquals($targetDocumentId, $event2['data']['payload']['$id']); + $this->assertEquals('updated-after-permission-change', $event2['data']['payload']['status']); + $this->assertArrayHasKey('subscriptions', $event2['data']); + $this->assertIsArray($event2['data']['subscriptions']); + $this->assertNotEmpty($event2['data']['subscriptions']); + // Subscription ID should remain stable after permission change + if ($originalSubscriptionId !== null) { + $this->assertContains($originalSubscriptionId, $event2['data']['subscriptions']); + } + + $client->close(); + } + + public function testProjectChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test OLD SDK behavior: project=projectId (string) in query param + // For reserved \"project\" param, string is treated as routing-only (project ID), + // and is not used as queries for the project channel. We should fall back to select(*). + $clientOldSdk = $this->getWebsocket(['project'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], $projectId, null); + + $response = $this->assertConnectionStatusIfSupported($clientOldSdk); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since project param was treated as project ID, not queries + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $clientOldSdk->close(); + + // Test NEW SDK behavior: project=Query array in query param, project ID in header + // The reserved param logic should use Query array as subscription queries for project channel + $queryArray = [Query::select(['*'])->toString()]; + $clientNewSdk = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0] + ] + ] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = $this->assertConnectionStatusIfSupported($clientNewSdk); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have subscription with the provided query + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $clientNewSdk->close(); + + // Test edge case: project param is array but not a valid Query array + // This should now fail with an invalid query error rather than silently falling back. + $clientEdgeCase = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => ['invalid', 'array'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($clientEdgeCase->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('Invalid query', $response['data']['message']); + } + + public function testTestsChannelWithQueries() + { + $projectId = 'console'; + + // Subscribe without queries - should receive all events + $clientNoQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + timeout: 5 + ); + + $this->assertConnectionStatusIfSupported($clientNoQuery); + + // Subscribe with matching query - should receive events + $clientWithMatchingQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + queries: [Query::equal('response', ['WS:/v1/realtime:passed'])->toString()], + timeout: 5 + ); + + $this->assertConnectionStatusIfSupported($clientWithMatchingQuery); + + // Subscribe with non-matching query - should NOT receive events + $clientWithNonMatchingQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + queries: [Query::equal('response', ['failed'])->toString()] + ); + + $this->assertConnectionStatusIfSupported($clientWithNonMatchingQuery); + + sleep(2); + + // Client without query should receive event + $eventNoQuery = json_decode($clientNoQuery->receive(), true); + $this->assertEquals('event', $eventNoQuery['type']); + $this->assertEquals('test.event', $eventNoQuery['data']['events'][0]); + $this->assertEquals('WS:/v1/realtime:passed', $eventNoQuery['data']['payload']['response']); + + // Client with matching query should receive event + $eventMatching = json_decode($clientWithMatchingQuery->receive(), true); + $this->assertEquals('event', $eventMatching['type']); + $this->assertEquals('test.event', $eventMatching['data']['events'][0]); + $this->assertEquals('WS:/v1/realtime:passed', $eventMatching['data']['payload']['response']); + + // Client with non-matching query should NOT receive event + try { + $clientWithNonMatchingQuery->receive(); + $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientNoQuery->close(); + $clientWithMatchingQuery->close(); + $clientWithNonMatchingQuery->close(); + } +} diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index e5bb06cc48..d1cb548016 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1071,6 +1071,97 @@ trait StorageBase $this->assertNotEmpty($preview['body']); } + public function testDeletePartiallyUploadedFile(): void + { + // Create a bucket for this test + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket Partial Upload', + 'fileSecurity' => true, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + // Simulate a partial (cancelled) chunked upload by sending only the first chunk + $source = __DIR__ . "/../../../resources/disk-a/large-file.mp4"; + $totalSize = \filesize($source); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = mime_content_type($source); + + $handle = fopen($source, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $source"); + $chunkData = fread($handle, $chunkSize); + fclose($handle); + + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunkData), + $mimeType, + 'large-file.mp4' + ); + + // Send only the first chunk (bytes 0 to chunkSize-1 of totalSize) + $end = min($chunkSize - 1, $totalSize - 1); + $partialFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . $end . '/' . $totalSize, + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => $curlFile, + 'permissions' => [ + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $partialFile['headers']['status-code']); + $fileId = $partialFile['body']['$id']; + + // Confirm the file is in a pending state (chunksTotal > chunksUploaded) + $this->assertGreaterThan( + $partialFile['body']['chunksUploaded'], + $partialFile['body']['chunksTotal'], + 'File should be partially uploaded (pending)' + ); + + // Delete the partially-uploaded (pending) file — this should succeed + $deleteResponse = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $deleteResponse['headers']['status-code']); + $this->assertEmpty($deleteResponse['body']); + + // Confirm the file is gone + $getResponse = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $getResponse['headers']['status-code']); + + // Clean up the test bucket + $deleteBucketResponse = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(204, $deleteBucketResponse['headers']['status-code']); + } + public function testDeleteBucketFile(): void { // Create a fresh file just for deletion testing (not using cache since we delete it) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 7ad701b564..f926a97c81 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -35,11 +35,11 @@ trait WebhooksBase $this->assertContains('users.*.create', $webhook['body']['events']); $this->assertCount(1, $webhook['body']['events']); $this->assertEquals(true, $webhook['body']['enabled']); - $this->assertEquals(false, $webhook['body']['security']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); - $this->assertNotEmpty($webhook['body']['signatureKey']); - $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + $this->assertEquals(false, $webhook['body']['tls']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); + $this->assertNotEmpty($webhook['body']['secret']); + $this->assertEquals(128, \strlen($webhook['body']['secret'])); $this->assertEquals(0, $webhook['body']['attempts']); $this->assertEquals('', $webhook['body']['logs']); @@ -63,11 +63,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testCreateWebhookWithSecurity(): void + public function testCreateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Webhook With Security', + 'Webhook With TLS', ['users.*.create'], null, 'https://appwrite.io', @@ -78,8 +78,8 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals(true, $webhook['body']['security']); - $this->assertIsBool($webhook['body']['security']); + $this->assertEquals(true, $webhook['body']['tls']); + $this->assertIsBool($webhook['body']['tls']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -100,14 +100,14 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals('username', $webhook['body']['httpUser']); - $this->assertEquals('password', $webhook['body']['httpPass']); - $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('username', $webhook['body']['authUsername']); + $this->assertEquals('password', $webhook['body']['authPassword']); + $this->assertEquals(true, $webhook['body']['tls']); // Verify via GET $get = $this->getWebhook($webhook['body']['$id']); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('username', $get['body']['httpUser']); + $this->assertEquals('username', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -331,11 +331,11 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } - public function testUpdateWebhookWithSecurity(): void + public function testUpdateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Webhook', + 'TLS Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -345,7 +345,7 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals(false, $webhook['body']['security']); + $this->assertEquals(false, $webhook['body']['tls']); $webhookId = $webhook['body']['$id']; // Update to enable security @@ -361,8 +361,8 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertIsBool($updated['body']['security']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertIsBool($updated['body']['tls']); // Cleanup $this->deleteWebhook($webhookId); @@ -382,8 +382,8 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); $webhookId = $webhook['body']['$id']; // Update with HTTP auth credentials @@ -399,13 +399,13 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals('newuser', $updated['body']['httpUser']); - $this->assertEquals('newpass', $updated['body']['httpPass']); + $this->assertEquals('newuser', $updated['body']['authUsername']); + $this->assertEquals('newpass', $updated['body']['authPassword']); // Verify via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('newuser', $get['body']['httpUser']); + $this->assertEquals('newuser', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhookId); @@ -657,19 +657,19 @@ trait WebhooksBase $this->assertContains('buckets.*.files.*.create', $updated['body']['events']); $this->assertCount(3, $updated['body']['events']); $this->assertEquals('https://appwrite.io/updated', $updated['body']['url']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertEquals('user', $updated['body']['httpUser']); - $this->assertEquals('pass', $updated['body']['httpPass']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertEquals('user', $updated['body']['authUsername']); + $this->assertEquals('pass', $updated['body']['authPassword']); // Cleanup $this->deleteWebhook($webhookId); } - public function testUpdateWebhookSignature(): void + public function testUpdateWebhookSecret(): void { $webhook = $this->createWebhook( ID::unique(), - 'Signature Webhook', + 'Secret Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -680,27 +680,27 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $originalSignatureKey = $webhook['body']['signatureKey']; + $originalSecret = $webhook['body']['secret']; - $this->assertNotEmpty($originalSignatureKey); - $this->assertEquals(128, \strlen($originalSignatureKey)); + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); - // Update signature - $updated = $this->updateWebhookSignature($webhookId); + // Update secret + $updated = $this->updateWebhookSecret($webhookId); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals($webhookId, $updated['body']['$id']); - $this->assertNotEmpty($updated['body']['signatureKey']); - $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); - $this->assertNotEquals($originalSignatureKey, $updated['body']['signatureKey']); + $this->assertNotEmpty($updated['body']['secret']); + $this->assertEquals(128, \strlen($updated['body']['secret'])); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new signature persisted via GET + // Verify secret is not exposed via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']); + $this->assertEmpty($get['body']['secret']); - // Test signature update on non-existent webhook - $notFound = $this->updateWebhookSignature('non-existent-id'); + // Test secret update on non-existent webhook + $notFound = $this->updateWebhookSecret('non-existent-id'); $this->assertEquals(404, $notFound['headers']['status-code']); $this->assertEquals('webhook_not_found', $notFound['body']['type']); @@ -708,6 +708,351 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } + public function testSecretRotationZeroDowntime(): void + { + // Create webhook pointing to request-catcher so deliveries are captured + $webhook = $this->createWebhook( + ID::unique(), + 'Rotation Test Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSecret = $webhook['body']['secret']; + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); + + // Step 1: Trigger user creation with the original auto-generated secret + $email1 = uniqid() . 'rotation1@localhost.test'; + $user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email1, + 'password' => 'password', + 'name' => 'Rotation User 1', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + $userId1 = $user1['body']['$id']; + + // Verify webhook delivery is signed with the original secret + $this->assertEventually(function () use ($userId1, $originalSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId1) { + $this->assertStringContainsString( + "users.{$userId1}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Step 2: Rotate the secret to a known custom value + $newSecret = 'new-key-after-rotation'; + $updated = $this->updateWebhookSecret($webhookId, $newSecret); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($newSecret, $updated['body']['secret']); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); + + // Step 3: Trigger another user creation — should be signed with the new secret + $email2 = uniqid() . 'rotation2@localhost.test'; + $user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email2, + 'password' => 'password', + 'name' => 'Rotation User 2', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + $userId2 = $user2['body']['$id']; + + // Verify webhook delivery is signed with the new rotated secret + $this->assertEventually(function () use ($userId2, $newSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId2) { + $this->assertStringContainsString( + "users.{$userId2}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookWithCustomSecret(): void + { + $customSecret = 'custom-secret-key'; + + // Create webhook with a custom secret pointing to request-catcher + $webhook = $this->createWebhook( + ID::unique(), + 'Custom Secret Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null, + $customSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals($customSecret, $webhook['body']['secret']); + + // Trigger user creation to generate a webhook delivery + $email = uniqid() . 'customsecret@localhost.test'; + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Custom Secret User', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Verify webhook delivery is signed with the custom secret + $this->assertEventually(function () use ($userId, $customSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId) { + $this->assertStringContainsString( + "users.{$userId}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookSecretMinLength(): void + { + // 7 chars — below minimum of 8 + $webhook = $this->createWebhook( + ID::unique(), + 'Short Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'short12' + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + + // 8 chars — exactly at minimum + $webhook = $this->createWebhook( + ID::unique(), + 'Min Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'exact8ch' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('exact8ch', $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testCreateWebhookSecretMaxLength(): void + { + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $webhook = $this->createWebhook( + ID::unique(), + 'Max Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $maxSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($maxSecret, $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $webhook = $this->createWebhook( + ID::unique(), + 'Too Long Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $tooLongSecret + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookSecretMinLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Min Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 7 chars — below minimum of 8 + $updated = $this->updateWebhookSecret($webhookId, 'short12'); + $this->assertEquals(400, $updated['headers']['status-code']); + + // 8 chars — exactly at minimum + $updated = $this->updateWebhookSecret($webhookId, 'exact8ch'); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('exact8ch', $updated['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretMaxLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Max Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $updated = $this->updateWebhookSecret($webhookId, $maxSecret); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($maxSecret, $updated['body']['secret']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $updated = $this->updateWebhookSecret($webhookId, $tooLongSecret); + $this->assertEquals(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testWebhookSecretNotExposedInResponses(): void + { + // Create webhook — secret IS returned on creation + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Exposure Test', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'my-custom-secret' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals('my-custom-secret', $webhook['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $webhook['body']); + + // Get webhook — secret must not be exposed + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEmpty($get['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $get['body']); + + // List webhooks — secret must not be exposed + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + foreach ($list['body']['webhooks'] as $item) { + $this->assertEmpty($item['secret']); + $this->assertArrayNotHasKey('signatureKey', $item); + } + + // Update webhook — secret must not be exposed + $updated = $this->updateWebhook( + $webhookId, + 'Secret Exposure Test Updated', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEmpty($updated['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $updated['body']); + + // Update webhook secret — secret IS returned on rotation + $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); + $this->assertEquals(200, $rotated['headers']['status-code']); + $this->assertEquals('rotated-secret-key', $rotated['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $rotated['body']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void @@ -883,6 +1228,12 @@ trait WebhooksBase { $customId = 'my-custom-webhook-id'; + // Clean up stale webhook from a previous run if it exists + $existing = $this->getWebhook($customId); + if ($existing['headers']['status-code'] === 200) { + $this->deleteWebhook($customId); + } + $webhook = $this->createWebhook( $customId, 'Custom ID Webhook', @@ -902,6 +1253,19 @@ trait WebhooksBase $this->assertEquals(200, $get['headers']['status-code']); $this->assertEquals($customId, $get['body']['$id']); + // Ensure duplicate creation fails + $duplicate = $this->createWebhook( + $customId, + 'Duplicate Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Cleanup $this->deleteWebhook($customId); } @@ -934,11 +1298,10 @@ trait WebhooksBase $this->assertContains('users.*.update.email', $get['body']['events']); $this->assertCount(2, $get['body']['events']); $this->assertEquals(true, $get['body']['enabled']); - $this->assertEquals(true, $get['body']['security']); - $this->assertEquals('myuser', $get['body']['httpUser']); - $this->assertEquals('mypass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); - $this->assertEquals(128, \strlen($get['body']['signatureKey'])); + $this->assertEquals(true, $get['body']['tls']); + $this->assertEquals('myuser', $get['body']['authUsername']); + $this->assertEquals('mypass', $get['body']['authPassword']); + $this->assertEmpty($get['body']['secret']); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1043,9 +1406,9 @@ trait WebhooksBase $this->assertArrayHasKey('name', $webhook); $this->assertArrayHasKey('url', $webhook); $this->assertArrayHasKey('events', $webhook); - $this->assertArrayHasKey('security', $webhook); + $this->assertArrayHasKey('tls', $webhook); $this->assertArrayHasKey('enabled', $webhook); - $this->assertArrayHasKey('signatureKey', $webhook); + $this->assertArrayHasKey('secret', $webhook); $this->assertArrayHasKey('attempts', $webhook); $this->assertArrayHasKey('logs', $webhook); } @@ -1247,11 +1610,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testListWebhooksFilterBySecurity(): void + public function testListWebhooksFilterByTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Filter Webhook', + 'TLS Filter Webhook', ['users.*.create'], null, 'https://appwrite.io/sec', @@ -1262,13 +1625,13 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $list = $this->listWebhooks([ - Query::equal('security', [true])->toString(), + Query::equal('tls', [true])->toString(), ], true); $this->assertEquals(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); foreach ($list['body']['webhooks'] as $w) { - $this->assertEquals(true, $w['security']); + $this->assertEquals(true, $w['tls']); } // Cleanup @@ -1503,6 +1866,254 @@ trait WebhooksBase $this->assertEquals('webhook_not_found', $delete['body']['type']); } + // ========================================================================= + // Backward compatibility tests (1.9.0 response format) + // ========================================================================= + + public function testCreateWebhookV22BackwardCompatRequest(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Send old param names with 1.9.0 header + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', $headers, [ + 'webhookId' => ID::unique(), + 'name' => 'V22 Compat Create', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'olduser', + 'httpPass' => 'oldpass', + ]); + + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $webhook['body']); + $this->assertArrayHasKey('httpUser', $webhook['body']); + $this->assertArrayHasKey('httpPass', $webhook['body']); + $this->assertArrayHasKey('signatureKey', $webhook['body']); + + // New field names should NOT be present + $this->assertArrayNotHasKey('tls', $webhook['body']); + $this->assertArrayNotHasKey('authUsername', $webhook['body']); + $this->assertArrayNotHasKey('authPassword', $webhook['body']); + $this->assertArrayNotHasKey('secret', $webhook['body']); + + // Values should be correct + $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('olduser', $webhook['body']['httpUser']); + $this->assertEquals('oldpass', $webhook['body']['httpPass']); + $this->assertNotEmpty($webhook['body']['signatureKey']); + $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testUpdateWebhookV22BackwardCompatRequest(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Update', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Update using old param names + $updated = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, $headers, [ + 'name' => 'V22 Compat Updated', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'updateduser', + 'httpPass' => 'updatedpass', + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + $this->assertArrayHasKey('signatureKey', $updated['body']); + + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + $this->assertArrayNotHasKey('secret', $updated['body']); + + $this->assertEquals(true, $updated['body']['security']); + $this->assertEquals('updateduser', $updated['body']['httpUser']); + $this->assertEquals('updatedpass', $updated['body']['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testGetWebhookV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Get', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'getuser', + 'getpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // GET with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $get = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, $headers); + + $this->assertEquals(200, $get['headers']['status-code']); + + // Should have old field names + $this->assertArrayHasKey('security', $get['body']); + $this->assertArrayHasKey('httpUser', $get['body']); + $this->assertArrayHasKey('httpPass', $get['body']); + $this->assertArrayHasKey('signatureKey', $get['body']); + + $this->assertArrayNotHasKey('tls', $get['body']); + $this->assertArrayNotHasKey('authUsername', $get['body']); + $this->assertArrayNotHasKey('authPassword', $get['body']); + $this->assertArrayNotHasKey('secret', $get['body']); + + $this->assertEquals(true, $get['body']['security']); + $this->assertEquals('getuser', $get['body']['httpUser']); + $this->assertEquals('getpass', $get['body']['httpPass']); + $this->assertEmpty($get['body']['signatureKey']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testListWebhooksV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat List', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'listuser', + 'listpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // LIST with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $list = $this->client->call(Client::METHOD_GET, '/webhooks', $headers, [ + 'queries' => [ + Query::equal('name', ['V22 Compat List'])->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['webhooks']); + + $item = $list['body']['webhooks'][0]; + + // Each item should have old field names + $this->assertArrayHasKey('security', $item); + $this->assertArrayHasKey('httpUser', $item); + $this->assertArrayHasKey('httpPass', $item); + $this->assertArrayHasKey('signatureKey', $item); + + $this->assertArrayNotHasKey('tls', $item); + $this->assertArrayNotHasKey('authUsername', $item); + $this->assertArrayNotHasKey('authPassword', $item); + $this->assertArrayNotHasKey('secret', $item); + + $this->assertEquals(true, $item['security']); + $this->assertEquals('listuser', $item['httpUser']); + $this->assertEquals('listpass', $item['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Secret', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Update secret with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', $headers); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use old field names + $this->assertArrayHasKey('signatureKey', $updated['body']); + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + + $this->assertNotEmpty($updated['body']['signatureKey']); + $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // Helpers /** @@ -1531,7 +2142,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword, ?string $secret = null): mixed { $params = [ 'webhookId' => $webhookId, @@ -1543,14 +2154,17 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; + } + if ($secret !== null) { + $params['secret'] = $secret; } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ @@ -1561,7 +2175,7 @@ trait WebhooksBase return $webhook; } - protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed { $params = [ 'name' => $name, @@ -1572,14 +2186,14 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; } $webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ @@ -1590,12 +2204,17 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSignature(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed { - $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $params = []; + if ($secret !== null) { + $params['secret'] = $secret; + } + + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ], $this->getHeaders()), $params); return $webhook; } diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index d050ce5f64..471dd5ad08 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Event; use Appwrite\Event\Event; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Utopia\Database\Document; require_once __DIR__ . '/../../../app/init.php'; @@ -115,7 +116,7 @@ class EventTest extends TestCase 'rowId' => 'prolog', ]); - $this->assertCount(22, $event); + $this->assertCount(42, $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog.create', $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog', $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.*.create', $event); @@ -156,4 +157,62 @@ class EventTest extends TestCase $this->assertInstanceOf(InvalidArgumentException::class, $th, 'An invalid exception was thrown'); } } + + public function testGenerateMirrorEvents(): void + { + $legacyDatabase = new Document(['type' => 'legacy']); + $tableRowEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'rowId' => 'row-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tableRowEvents); + + $collectionDocumentEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'documentId' => 'doc-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.tables.assembly.rows.doc-123.update', $collectionDocumentEvents); + + $tableColumnEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].columns.[columnId].create', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'columnId' => 'status', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.assembly.attributes.status.create', $tableColumnEvents); + + $collectionAttributeEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].attributes.[attributeId].create', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'attributeId' => 'status', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.tables.assembly.columns.status.create', $collectionAttributeEvents); + + $tablesDb = new Document(['type' => 'tablesdb']); + $tablesDbEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'rowId' => 'row-123', + ], $tablesDb); + $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tablesDbEvents); + $this->assertContains('tablesdb.factory-db.tables.assembly.rows.row-123.update', $tablesDbEvents); + $tableIdWithReservedWordEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'rows-archive', + 'rowId' => 'row-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.rows-archive.documents.row-123.update', $tableIdWithReservedWordEvents); + $this->assertNotContains('databases.factory-db.collections.documents-archive.documents.row-123.update', $tableIdWithReservedWordEvents); + + $documentsDb = new Document(['type' => 'documentsdb']); + $documentsDbEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'documentId' => 'doc-123', + ], $documentsDb); + $this->assertContains('documentsdb.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents); + $this->assertNotContains('documentsdb.factory-db.tables.assembly.rows.doc-123.update', $documentsDbEvents); + $this->assertNotContains('databases.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents); + } } diff --git a/tests/unit/GraphQL/BuilderTest.php b/tests/unit/GraphQL/BuilderTest.php index 3dd1bcadc7..9190ce3e78 100644 --- a/tests/unit/GraphQL/BuilderTest.php +++ b/tests/unit/GraphQL/BuilderTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\GraphQL; use Appwrite\GraphQL\Types\Mapper; use Appwrite\Utopia\Response; +use GraphQL\Type\Definition\NamedType; use PHPUnit\Framework\TestCase; use Swoole\Http\Response as SwooleResponse; @@ -24,6 +25,7 @@ class BuilderTest extends TestCase { $model = $this->response->getModel(Response::MODEL_TABLE); $type = Mapper::model(\ucfirst($model->getType())); - $this->assertEquals('Table', $type->name); + $this->assertInstanceOf(NamedType::class, $type); + $this->assertEquals('Table', $type->name()); } } diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php new file mode 100644 index 0000000000..f99b29bfe2 --- /dev/null +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -0,0 +1,66 @@ +getRequestParameterConfig($service, $method, $param, $optional, $nullable, $default); + } +} + +class FormatTest extends TestCase +{ + private TestFormat $format; + + protected function setUp(): void + { + parent::setUp(); + + $this->format = new TestFormat(new Container(), [], [], [], [], 0, 'console'); + } + + public function testProjectRequestParameterOverrides(): void + { + $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); + $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); + $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); + + $this->assertTrue($createWebPlatform['required']); + $this->assertFalse($createWebPlatform['emitDefault']); + $this->assertTrue($updateWebPlatform['required']); + $this->assertFalse($updateWebPlatform['emitDefault']); + $this->assertTrue($listPlatforms['emitDefault']); + } + + public function testProjectPlatformResponseTypeUsesSharedEnumName(): void + { + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformAndroid', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWeb', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformApple', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWindows', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformLinux', 'type')); + $this->assertNull($this->format->getResponseEnumName('platformList', 'type')); + } + + public function testExistingResponseEnumMappingsRemainUnchanged(): void + { + $this->assertSame('HealthCheckStatus', $this->format->getResponseEnumName('healthStatus', 'status')); + $this->assertNull($this->format->getResponseEnumName('key', 'name')); + } +}