diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac16c80264..08804bd723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -442,7 +442,8 @@ jobs: VCS, Messaging, Migrations, - Project + Project, + Presences ] include: - service: Databases diff --git a/app/cli.php b/app/cli.php index 9ad223a3ff..496a79eab9 100644 --- a/app/cli.php +++ b/app/cli.php @@ -2,18 +2,10 @@ require_once __DIR__ . '/init.php'; -use Appwrite\Event\Event; -use Appwrite\Event\Publisher\Certificate as CertificatePublisher; -use Appwrite\Event\Publisher\Database as DatabasePublisher; -use Appwrite\Event\Publisher\Delete as DeletePublisher; -use Appwrite\Event\Publisher\Func as FunctionPublisher; -use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; -use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Platform\Appwrite; use Appwrite\Runtimes\Runtimes; use Appwrite\Usage\Context as UsageContext; use Appwrite\Utopia\Database\Documents\User; -use Executor\Executor; use Swoole\Runtime; use Swoole\Timer; use Utopia\Cache\Adapter\Pool as CachePool; @@ -27,17 +19,12 @@ use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; -use Utopia\DI\Container; use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Platform\Service; use Utopia\Pools\Group; -use Utopia\Queue\Broker\Pool as BrokerPool; -use Utopia\Queue\Publisher; -use Utopia\Queue\Queue; use Utopia\Registry\Registry; use Utopia\System\System; -use Utopia\Telemetry\Adapter\None as NoTelemetry; use function Swoole\Coroutine\run; @@ -48,6 +35,7 @@ Config::setParam('runtimes', (new Runtimes('v5'))->getAll(supported: false)); require_once __DIR__ . '/controllers/general.php'; global $register; +global $container; $platform = new Appwrite(); $args = $_SERVER['argv'] ?? []; @@ -59,7 +47,6 @@ if (! isset($args[0])) { } $taskName = $args[0]; -$container = new Container(); $cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container); $platform->setCli($cli); @@ -132,10 +119,6 @@ $container->set('dbForPlatform', function ($pools, $cache, $authorization) { return $dbForPlatform; }, ['pools', 'cache', 'authorization']); -$container->set('console', function () { - return new Document(Config::getParam('console')); -}, []); - $container->set( 'isResourceBlocked', fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false, @@ -252,48 +235,10 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization return $database; }; }, ['pools', 'cache', 'authorization']); -$container->set('publisher', function (Group $pools) { - return new BrokerPool(publisher: $pools->get('publisher')); -}, ['pools']); -$container->set('publisherDatabases', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherFunctions', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherMigrations', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherMessaging', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); + $container->set('usage', function () { return new UsageContext(); }, []); -$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('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('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher( - $publisher, - new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL) -), ['publisher']); -$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher( - $publisherDatabases, - new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME)) -), ['publisherDatabases']); -$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher( - $publisher, - new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME)) -), ['publisher']); $container->set('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { Console::error('[Error] Timestamp: ' . date('c', time())); @@ -346,14 +291,10 @@ $container->set('logError', function (Registry $register) { }; }, ['register']); -$container->set('executor', fn () => new Executor(), []); - $container->set('bus', function (Registry $register) use ($container) { return $register->get('bus')->setResolver(fn (string $name) => $container->get($name)); }, ['register']); -$container->set('telemetry', fn () => new NoTelemetry(), []); - $exitCode = 0; $cli diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 9568c59369..933de12290 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2754,4 +2754,146 @@ return [ ], ], ], + + // Naming it presenceLogs as later it might be only be used as a presence events table only and not for the actual presence + 'presenceLogs' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('presenceLogs'), + 'name' => 'Presence Logs', + 'attributes' => [ + [ + '$id' => ID::custom('userInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('userId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('source'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('hostname'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('metadata'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => new \stdClass(), + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('permissionsHash'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 32, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_unique_userId'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['userId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_userInternal'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['userInternalId'], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_expiresAt'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['status'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_source'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['source'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_source_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['source', 'status'] + ], + [ + '$id' => ID::custom('_key_permissionsHash'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['permissionsHash'] + ] + ] + ] ]; diff --git a/app/config/errors.php b/app/config/errors.php index 42ce9ac91b..4a6f08d432 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -725,6 +725,18 @@ return [ 'code' => 404, ], + /** Presence */ + Exception::PRESENCE_NOT_FOUND => [ + 'name' => Exception::PRESENCE_NOT_FOUND, + 'description' => 'Presence with the requested ID could not be found.', + 'code' => 404, + ], + Exception::PRESENCE_ALREADY_EXISTS => [ + 'name' => Exception::PRESENCE_ALREADY_EXISTS, + 'description' => 'Presence with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'code' => 409, + ], + /** Databases */ Exception::DATABASE_NOT_FOUND => [ 'name' => Exception::DATABASE_NOT_FOUND, diff --git a/app/config/frameworks.php b/app/config/frameworks.php index 6078c53c63..342657017f 100644 --- a/app/config/frameworks.php +++ b/app/config/frameworks.php @@ -14,7 +14,11 @@ return [ 'name' => 'Analog', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/analog/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/analog/env.sh', 'adapters' => [ @@ -40,7 +44,11 @@ return [ 'name' => 'Angular', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/angular/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/angular/env.sh', 'adapters' => [ @@ -66,7 +74,11 @@ return [ 'name' => 'Next.js', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/next-js/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/next-js/env.sh', 'adapters' => [ @@ -91,7 +103,11 @@ return [ 'name' => 'React', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'adapters' => [ 'static' => [ 'key' => 'static', @@ -108,7 +124,11 @@ return [ 'name' => 'Nuxt', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/nuxt/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/nuxt/env.sh', 'adapters' => [ @@ -133,7 +153,11 @@ return [ 'name' => 'Vue.js', 'screenshotSleep' => 5000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'adapters' => [ 'static' => [ 'key' => 'static', @@ -150,7 +174,11 @@ return [ 'name' => 'SvelteKit', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/sveltekit/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/sveltekit/env.sh', 'adapters' => [ @@ -175,7 +203,11 @@ return [ 'name' => 'Astro', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/astro/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/astro/env.sh', 'adapters' => [ @@ -200,7 +232,11 @@ return [ 'name' => 'TanStack Start', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/tanstack-start/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/tanstack-start/env.sh', 'adapters' => [ @@ -225,7 +261,11 @@ return [ 'name' => 'Remix', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'bundleCommand' => 'bash /usr/local/server/helpers/remix/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/remix/env.sh', 'adapters' => [ @@ -250,7 +290,11 @@ return [ 'name' => 'Lynx', 'screenshotSleep' => 5000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'adapters' => [ 'static' => [ 'key' => 'static', @@ -284,7 +328,11 @@ return [ 'name' => 'React Native', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'adapters' => [ 'static' => [ 'key' => 'static', @@ -301,7 +349,11 @@ return [ 'name' => 'Vite', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'adapters' => [ 'static' => [ 'key' => 'static', @@ -317,7 +369,11 @@ return [ 'name' => 'Other', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => $templateRuntimes['NODE'], + 'runtimes' => array_merge( + $templateRuntimes['NODE'], + $templateRuntimes['BUN'], + $templateRuntimes['DENO'] + ), 'adapters' => [ 'static' => [ 'key' => 'static', diff --git a/app/config/roles.php b/app/config/roles.php index cb4b178a29..abb8d4481f 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -12,6 +12,8 @@ $member = [ 'account', 'teams.read', 'teams.write', + 'presences.read', + 'presences.write', 'documents.read', 'documents.write', 'rows.read', @@ -47,6 +49,8 @@ $admins = [ 'buckets.write', 'users.read', 'users.write', + 'presences.read', + 'presences.write', 'databases.read', 'databases.write', 'collections.read', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 3d8998fb2f..cd50abd57b 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -379,4 +379,12 @@ return [ 'description' => 'Access to delete reports under Advisor service.', 'category' => 'Advisor', ], + 'presences.read' => [ + 'description' => 'Access to read your project\'s presences', + 'category' => 'Presences', + ], + 'presences.write' => [ + 'description' => 'Access to create, update, and delete your project\'s presences', + 'category' => 'Presences', + ], ]; diff --git a/app/config/sdks.php b/app/config/sdks.php index e29b28690f..36a973167d 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -8,55 +8,6 @@ return [ 'enabled' => true, 'beta' => false, 'sdks' => [ - [ - 'key' => 'web', - 'name' => 'Web', - 'version' => '22.4.0', - 'url' => 'https://github.com/appwrite/sdk-for-web', - 'package' => 'https://www.npmjs.com/package/appwrite', - 'enabled' => true, - 'beta' => false, - 'dev' => false, - 'hidden' => false, - 'family' => APP_SDK_PLATFORM_CLIENT, - 'prism' => 'javascript', - 'source' => \realpath(__DIR__ . '/../sdks/client-web'), - 'gitUrl' => 'git@github.com:appwrite/sdk-for-web.git', - 'gitRepoName' => 'sdk-for-web', - 'gitUserName' => 'appwrite', - 'gitBranch' => 'dev', - 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/web/CHANGELOG.md'), - 'demos' => [ - [ - 'icon' => 'react.svg', - 'name' => 'Todo App with React JS', - 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', - 'source' => 'https://github.com/appwrite/todo-with-react', - 'url' => 'https://appwrite-todo-with-react.vercel.app/', - ], - [ - 'icon' => 'vue.svg', - 'name' => 'Todo App with Vue JS', - 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', - 'source' => 'https://github.com/appwrite/todo-with-vue', - 'url' => 'https://appwrite-todo-with-vue.vercel.app/', - ], - [ - 'icon' => 'angular.svg', - 'name' => 'Todo App with Angular', - 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', - 'source' => 'https://github.com/appwrite/todo-with-angular', - 'url' => 'https://appwrite-todo-with-angular.vercel.app/', - ], - [ - 'icon' => 'svelte.svg', - 'name' => 'Todo App with Svelte', - 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', - 'source' => 'https://github.com/appwrite/todo-with-svelte', - 'url' => 'https://appwrite-todo-with-svelte.vercel.app/', - ], - ] - ], [ 'key' => 'flutter', 'name' => 'Flutter', @@ -350,6 +301,55 @@ return [ 'enabled' => true, 'beta' => false, 'sdks' => [ + [ + 'key' => 'web', + 'name' => 'Web', + 'version' => '22.4.0', + 'url' => 'https://github.com/appwrite/sdk-for-web', + 'package' => 'https://www.npmjs.com/package/appwrite', + 'enabled' => true, + 'beta' => false, + 'dev' => false, + 'hidden' => false, + 'family' => APP_SDK_PLATFORM_SERVER, + 'prism' => 'javascript', + 'source' => \realpath(__DIR__ . '/../sdks/client-web'), + 'gitUrl' => 'git@github.com:appwrite/sdk-for-web.git', + 'gitRepoName' => 'sdk-for-web', + 'gitUserName' => 'appwrite', + 'gitBranch' => 'dev', + 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/web/CHANGELOG.md'), + 'demos' => [ + [ + 'icon' => 'react.svg', + 'name' => 'Todo App with React JS', + 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', + 'source' => 'https://github.com/appwrite/todo-with-react', + 'url' => 'https://appwrite-todo-with-react.vercel.app/', + ], + [ + 'icon' => 'vue.svg', + 'name' => 'Todo App with Vue JS', + 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', + 'source' => 'https://github.com/appwrite/todo-with-vue', + 'url' => 'https://appwrite-todo-with-vue.vercel.app/', + ], + [ + 'icon' => 'angular.svg', + 'name' => 'Todo App with Angular', + 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', + 'source' => 'https://github.com/appwrite/todo-with-angular', + 'url' => 'https://appwrite-todo-with-angular.vercel.app/', + ], + [ + 'icon' => 'svelte.svg', + 'name' => 'Todo App with Svelte', + 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.', + 'source' => 'https://github.com/appwrite/todo-with-svelte', + 'url' => 'https://appwrite-todo-with-svelte.vercel.app/', + ], + ] + ], [ 'key' => 'nodejs', 'name' => 'Node.js', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index e12263a009..c7da65f818 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1239,7 +1239,6 @@ Http::get('/v1/account/sessions/oauth2/:provider') ) ], contentType: ContentType::HTML, - hide: [APP_SDK_PLATFORM_SERVER], )) ->label('abuse-limit', 50) ->label('abuse-key', 'ip:{ip}') @@ -4515,7 +4514,7 @@ Http::post('/v1/account/targets/push') group: 'pushTargets', name: 'createPushTarget', description: '/docs/references/account/create-push-target.md', - auth: [AuthType::ADMIN, AuthType::SESSION], + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT], responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, @@ -4599,7 +4598,7 @@ Http::put('/v1/account/targets/:targetId/push') group: 'pushTargets', name: 'updatePushTarget', description: '/docs/references/account/update-push-target.md', - auth: [AuthType::ADMIN, AuthType::SESSION], + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, @@ -4669,7 +4668,7 @@ Http::delete('/v1/account/targets/:targetId/push') group: 'pushTargets', name: 'deletePushTarget', description: '/docs/references/account/delete-push-target.md', - auth: [AuthType::ADMIN, AuthType::SESSION], + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT], responses: [ new SDKResponse( code: Response::STATUS_CODE_NOCONTENT, diff --git a/app/controllers/general.php b/app/controllers/general.php index 6ca0a63ee2..b39c2e2623 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -184,31 +184,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S /** @var Database $dbForProject */ $dbForProject = $getProjectDB($project); - if (!empty($rule->getAttribute('deploymentId', ''))) { - $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId'))); - } else { - // 1.6.x DB schema compatibility - // TODO: Make sure deploymentId is never empty, and remove this code - - // Check if site or function; should never be site, but better safe than sorry - // Attempts to use attribute from both schemas (1.6 and 1.7) - $resourceType = $rule->getAttribute('deploymentResourceType', $rule->getAttribute('resourceType', '')); - - // ID of site or function - $resourceId = $rule->getAttribute('deploymentResourceId', ''); - - // Document of site or function - $resource = $resourceType === 'function' ? - $authorization->skip(fn () => $dbForProject->getDocument('functions', $resourceId)) : - $authorization->skip(fn () => $dbForProject->getDocument('sites', $resourceId)); - - // ID of active deployments - // Attempts to use attribute from both schemas (1.6 and 1.7) - $activeDeploymentId = $resource->getAttribute('deploymentId', $resource->getAttribute('deployment', '')); - - // Get deployment document, as intended originally - $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId)); - } + $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId'))); if ($deployment->isEmpty()) { $resourceType = $rule->getAttribute('deploymentResourceType', ''); diff --git a/app/init/constants.php b/app/init/constants.php index a0ebd130fd..75cb467be6 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -395,6 +395,7 @@ const METRIC_NETWORK_OUTBOUND = 'network.outbound'; const METRIC_MAU = 'users.mau'; const METRIC_DAU = 'users.dau'; const METRIC_WAU = 'users.wau'; +const METRIC_USERS_PRESENCE = 'users.presence'; const METRIC_WEBHOOKS = 'webhooks'; const METRIC_PLATFORMS = 'platforms'; const METRIC_PROVIDERS = 'providers'; diff --git a/app/init/models.php b/app/init/models.php index 521a3b77cd..0d1cb061ea 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -174,6 +174,7 @@ use Appwrite\Utopia\Response\Model\PolicySessionInvalidation; use Appwrite\Utopia\Response\Model\PolicySessionLimit; use Appwrite\Utopia\Response\Model\PolicyUserLimit; use Appwrite\Utopia\Response\Model\Preferences; +use Appwrite\Utopia\Response\Model\Presence; use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\ProjectAuthMethod; use Appwrite\Utopia\Response\Model\ProjectProtocol; @@ -214,6 +215,7 @@ use Appwrite\Utopia\Response\Model\UsageDocumentsDB; use Appwrite\Utopia\Response\Model\UsageDocumentsDBs; use Appwrite\Utopia\Response\Model\UsageFunction; use Appwrite\Utopia\Response\Model\UsageFunctions; +use Appwrite\Utopia\Response\Model\UsagePresence; use Appwrite\Utopia\Response\Model\UsageProject; use Appwrite\Utopia\Response\Model\UsageSite; use Appwrite\Utopia\Response\Model\UsageSites; @@ -237,6 +239,7 @@ Response::setModel(new ErrorDev()); // Lists Response::setModel(new BaseList('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW)); Response::setModel(new BaseList('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT)); +Response::setModel(new BaseList('Presences List', Response::MODEL_PRESENCE_LIST, 'presences', Response::MODEL_PRESENCE)); Response::setModel(new BaseList('Tables List', Response::MODEL_TABLE_LIST, 'tables', Response::MODEL_TABLE)); Response::setModel(new BaseList('Collections List', Response::MODEL_COLLECTION_LIST, 'collections', Response::MODEL_COLLECTION)); Response::setModel(new BaseList('Databases List', Response::MODEL_DATABASE_LIST, 'databases', Response::MODEL_DATABASE)); @@ -361,6 +364,7 @@ Response::setModel(new Index()); Response::setModel(new ColumnIndex()); Response::setModel(new Row()); Response::setModel(new ModelDocument()); +Response::setModel(new Presence()); Response::setModel(new Log()); Response::setModel(new User()); Response::setModel(new AlgoMd5()); @@ -489,6 +493,7 @@ Response::setModel(new UsageDatabase()); Response::setModel(new UsageTable()); Response::setModel(new UsageCollection()); Response::setModel(new UsageUsers()); +Response::setModel(new UsagePresence()); Response::setModel(new UsageStorage()); Response::setModel(new UsageBuckets()); Response::setModel(new UsageFunctions()); diff --git a/app/init/registers.php b/app/init/registers.php index 21ce536a8b..9ea19eee24 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -338,7 +338,13 @@ $register->set('pools', function () { $poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool(); - $pool = new Pool($poolAdapter, $name, $poolSize, function () use ($type, $resource, $dsn) { + // PubSub workers hold one long-lived subscribed connection and also need + // spare capacity for publishes from the same process. + $connectionPoolSize = $type === 'pubsub' + ? max(2, $poolSize) + : $poolSize; + + $pool = new Pool($poolAdapter, $name, $connectionPoolSize, function () use ($type, $resource, $dsn) { // Get Adapter switch ($type) { case 'database': diff --git a/app/init/resources.php b/app/init/resources.php index d48a60c06c..7b7e13482c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -53,6 +53,93 @@ global $register; global $container; $container = new Container(); +$container->set('console', fn () => new Document(Config::getParam('console')), []); + +$container->set('executor', fn () => new Executor(), []); + +$container->set('telemetry', fn () => new NoTelemetry(), []); + +$container->set('publisher', fn (Group $pools) => new BrokerPool(publisher: $pools->get('publisher')), ['pools']); + +$container->set('publisherDatabases', fn (Publisher $publisher) => $publisher, ['publisher']); + +$container->set('publisherFunctions', fn (Publisher $publisher) => $publisher, ['publisher']); + +$container->set('publisherMigrations', fn (Publisher $publisher) => $publisher, ['publisher']); + +$container->set('publisherMails', fn (Publisher $publisher) => $publisher, ['publisher']); + +$container->set('publisherDeletes', fn (Publisher $publisher) => $publisher, ['publisher']); + +$container->set('publisherMessaging', fn (Publisher $publisher) => $publisher, ['publisher']); + +$container->set('publisherWebhooks', fn (Publisher $publisher) => $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('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher( + $publisher, + new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL) +), ['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']); + +$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher( + $publisher, + new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) +), ['publisher']); + +$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher( + $publisherDatabases, + new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME)) +), ['publisherDatabases']); + +$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher( + $publisher, + new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME)) +), ['publisher']); + +$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher( + $publisher, + new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME)) +), ['publisher']); + +$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher( + $publisher, + new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME)) +), ['publisher']); + $container->set('logger', function ($register) { return $register->get('logger'); }, ['register']); @@ -67,83 +154,6 @@ $container->set('localeCodes', function () { return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', [])); }); -// Queues - shared infrastructure (stateless pool wrappers) -$container->set('publisher', function (Group $pools) { - return new BrokerPool(publisher: $pools->get('publisher')); -}, ['pools']); -$container->set('publisherDatabases', function (Publisher $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherFunctions', function (Publisher $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherMigrations', function (Publisher $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherMails', function (Publisher $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherDeletes', function (Publisher $publisher) { - return $publisher; -}, ['publisher']); -$container->set('publisherMessaging', function (Publisher $publisher) { - return $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('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher( - $publisher, - new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL) -), ['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']); -$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher( - $publisher, - new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) -), ['publisher']); -$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher( - $publisherDatabases, - new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME)) -), ['publisherDatabases']); -$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher( - $publisher, - new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME)) -), ['publisher']); -$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher( - $publisher, - new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME)) -), ['publisher']); -$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher( - $publisher, - new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME)) -), ['publisher']); /** * Platform configuration @@ -152,10 +162,6 @@ $container->set('platform', function () { return Config::getParam('platform', []); }, []); -$container->set('console', function () { - return new Document(Config::getParam('console')); -}, []); - $container->set('authorization', function () { return new Authorization(); }, []); @@ -214,8 +220,6 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization }; }, ['pools', 'cache', 'authorization']); -$container->set('telemetry', fn () => new NoTelemetry()); - $container->set('cache', function (Group $pools, Telemetry $telemetry) { $list = Config::getParam('pools-cache', []); $adapters = []; @@ -416,5 +420,3 @@ $container->set( 'isResourceBlocked', fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false ); - -$container->set('executor', fn () => new Executor()); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 3585421a28..5cabfc7859 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,7 +1,6 @@ set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher( - $publisher, - new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL) - ), ['publisher']); $container->set('queueForRealtime', function () { return new Realtime(); }, []); diff --git a/app/realtime.php b/app/realtime.php index 9f42d77461..d8b70960b8 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1,10 +1,20 @@ has('pools')) { + $container->set('pools', function ($register) { + return $register->get('pools'); + }, ['register']); +} + +if (!$container->has('publisherForUsage')) { + $container->set('publisherForUsage', function (Group $pools): UsagePublisher { + $statsUsageConnection = System::getEnv('_APP_CONNECTIONS_QUEUE_STATS_USAGE', ''); + $publisherPoolName = 'publisher'; + + if (!empty($statsUsageConnection)) { + try { + $pools->get('publisher_' . $statsUsageConnection); + $publisherPoolName = 'publisher_' . $statsUsageConnection; + } catch (Throwable) { + // Fallback to default publisher pool when custom one is unavailable. + } + } + + return new UsagePublisher( + new BrokerPool(publisher: $pools->get($publisherPoolName)), + new Queue(System::getEnv( + '_APP_STATS_USAGE_QUEUE_NAME', + QueueEvent::STATS_USAGE_QUEUE_NAME + )) + ); + }, ['pools']); +} + // Allows overriding if (!function_exists('getConsoleDB')) { function getConsoleDB(): Database @@ -234,6 +277,7 @@ if (!function_exists('getRealtime')) { } } + if (!function_exists('getTelemetry')) { function getTelemetry(int $workerId): Utopia\Telemetry\Adapter { @@ -247,18 +291,58 @@ if (!function_exists('getTelemetry')) { } } +if (!function_exists('getQueueForEvents')) { + function getQueueForEvents(): QueueEvent + { + $ctx = Coroutine::getContext(); + + if (!isset($ctx['queueForEvents'])) { + global $register; + /** @var Group $pools */ + $pools = $register->get('pools'); + $ctx['queueForEvents'] = new QueueEvent(new BrokerPool( + publisher: $pools->get('publisher') + )); + } + + return $ctx['queueForEvents']; + } +} + +if (!function_exists('getQueueForRealtime')) { + function getQueueForRealtime(): QueueRealtime + { + $ctx = Coroutine::getContext(); + + if (!isset($ctx['queueForRealtime'])) { + $ctx['queueForRealtime'] = new QueueRealtime(); + } + + return $ctx['queueForRealtime']; + } +} + if (!function_exists('triggerStats')) { function triggerStats(array $event, string $projectId): void { } } -global $container; -$container->set('pools', function ($register) { - return $register->get('pools'); -}, ['register']); +if (!function_exists('checkForProjectUsage')) { + function checkForProjectUsage(Document $project): void + { + } +} $realtime = getRealtime(); +$presenceState = new PresenceState(); + +$messageDispatcher = (new MessageDispatcher()) + ->addHandler(new PingHandler()) + ->addHandler(new AuthenticationHandler()) + ->addHandler(new SubscribeHandler()) + ->addHandler(new UnsubscribeHandler()) + ->addHandler(new PresenceHandler()); /** * Table for statistics across all workers. @@ -292,7 +376,16 @@ if (!function_exists('logError')) { $logger = $register->get('realtimeLogger'); - if ($logger && !$error instanceof Exception) { + // Match HTTP semantics (app/controllers/general.php): AppwriteException uses its + // configured publish flag; everything else publishes only for code 0 or >= 500. + // Without this, expected client errors (e.g. Utopia DB Authorization) hit Sentry. + if ($error instanceof AppwriteException) { + $publish = $error->isPublishable(); + } else { + $publish = $error->getCode() === 0 || $error->getCode() >= 500; + } + + if ($logger && $publish) { $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); $log = new Log(); @@ -612,6 +705,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, } } + // Strip deleted presences from in-memory connection state so onClose doesn't + // re-fire delete events for rows already removed via HTTP DELETE. + $deletedPresenceId = Realtime::extractDeletedPresenceId($event); + if ($deletedPresenceId !== null) { + $realtime->removePresenceFromConnections( + (string) ($event['project'] ?? ''), + $deletedPresenceId, + ); + } + $receivers = $realtime->getSubscribers($event); if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) { @@ -898,6 +1001,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $success = true; } catch (Throwable $th) { + Span::error($th); + + // Convert known Utopia DB exceptions to AppwriteException so isPublishable() + // suppresses expected client errors (permission denied, query timeout) from Sentry. + if ($th instanceof AuthorizationException) { + $th = new AppwriteException(AppwriteException::USER_UNAUTHORIZED, previous: $th); + } elseif ($th instanceof TimeoutException) { + $th = new AppwriteException(AppwriteException::DATABASE_TIMEOUT, previous: $th); + } + logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); // Handle SQL error code is 'HY000' @@ -933,7 +1046,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); } - Span::error($th); } finally { Span::add('realtime.success', $success); Span::add('realtime.response_code', $responseCode); @@ -951,15 +1063,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { +$server->onMessage(function (int $connection, string $message) use ($container, $server, $realtime, $containerId, $register, $presenceState, $messageDispatcher) { $project = null; $authorization = null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; $rawSize = \strlen($message); $messageType = 'invalid'; - $subscriptionDelta = 0; - $subscriptionsRequested = 0; - $subscriptionsRemoved = 0; $outboundBytes = 0; $responseCode = 200; $success = false; @@ -972,17 +1081,44 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re try { $response = new Response(new SwooleResponse()); - // Get authorization from connection (stored during onOpen) - $authorization = $realtime->connections[$connection]['authorization'] ?? null; - if ($authorization === null) { - $authorization = new Authorization(); + // Build a fresh Authorization per message. The connection-scoped instance is shared + // across coroutines, and `Authorization::skip()` toggles instance state — concurrent + // messages on the same connection (e.g. `authentication` + `presence` sent back-to-back) + // would interleave skip/restore and leak permission checks into supposedly-skipped lookups. + $authorization = new Authorization(); + $connectionAuthorization = $realtime->connections[$connection]['authorization'] ?? null; + if ($connectionAuthorization !== null) { + foreach ($connectionAuthorization->getRoles() as $role) { + $authorization->addRole($role); + } + } + $connectionRoles = $realtime->connections[$connection]['roles'] ?? []; + foreach ($connectionRoles as $role) { + if ($authorization->hasRole($role)) { + continue; + } + $authorization->addRole($role); } $database = getConsoleDB(); $database->setAuthorization($authorization); if (!empty($projectId) && $projectId !== 'console') { - $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId)); + // Negative-cache race: if any prior code path queried projects:$projectId + // before this project existed (e.g. a router probe during connection + // setup), the Database's shared cache may hold an empty result. Try the + // cached read first, and only purge/retry when the first lookup reports + // not-found so the shared cache remains effective for normal traffic. + try { + $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId)); + } catch (AppwriteException $e) { + if ($e->getCode() !== 404) { + throw $e; + } + + $database->purgeCachedDocument('projects', $projectId); + $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId)); + } $database = getProjectDB($project); $database->setAuthorization($authorization); @@ -990,6 +1126,10 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $project = null; } + if ($project !== null) { + checkForProjectUsage($project); + } + /* * Abuse Check * @@ -1008,6 +1148,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } // Record realtime inbound bytes for this project + // not making this a part of the dispatcher as we need to get the inbound bytes as well even if we dont enter the dispatcher if ($project !== null && !$project->isEmpty()) { triggerStats([ METRIC_REALTIME_INBOUND => $rawSize, @@ -1026,300 +1167,54 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } - // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) - if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { - throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); + // Child of the global container: per-message values like $connection and $project + // live on this scope so concurrent message coroutines don't clobber each other, + // while globally-registered services (pools, ...) remain reachable via the parent. + $messageContainer = new Container($container); + $messageContainer->set('connectionId', fn () => $connection); + $messageContainer->set('server', fn () => $server); + $messageContainer->set('realtime', fn () => $realtime); + $messageContainer->set('register', fn () => $register); + $messageContainer->set('response', fn () => $response); + $messageContainer->set('presenceState', fn () => $presenceState); + $messageContainer->set('database', fn () => $database); + $messageContainer->set('authorization', fn () => $authorization); + $messageContainer->set('project', fn () => $project); + $messageContainer->set('projectId', fn () => $projectId); + $messageContainer->set('queueForEvents', fn () => getQueueForEvents()); + $messageContainer->set('queueForRealtime', fn () => getQueueForRealtime()); + + $responsePayload = $messageDispatcher->dispatch($messageContainer, $message); + + if ($responsePayload !== null) { + $responseJson = json_encode($responsePayload); + if ($responseJson === false) { + throw new \RuntimeException( + 'Failed to encode realtime response payload: ' . json_last_error_msg() + ); + } + + $server->send([$connection], $responseJson); + $bytes = \strlen($responseJson); + $outboundBytes += $bytes; + + if ($project !== null && !$project->isEmpty()) { + triggerStats([METRIC_REALTIME_OUTBOUND => $bytes], $project->getId()); + } } - switch ($message['type']) { - case 'ping': - $pongPayloadJson = json_encode([ - 'type' => 'pong' - ]); - - $server->send([$connection], $pongPayloadJson); - $outboundBytes += \strlen($pongPayloadJson); - - if ($project !== null && !$project->isEmpty()) { - $pongOutboundBytes = \strlen($pongPayloadJson); - - if ($pongOutboundBytes > 0) { - triggerStats([ - METRIC_REALTIME_OUTBOUND => $pongOutboundBytes, - ], $project->getId()); - } - } - - break; - case 'authentication': - if (!array_key_exists('session', $message['data'])) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); - } - - $store = new Store(); - - $store->decode($message['data']['session']); - - /** @var User $user */ - $user = $database->getDocument('users', $store->getProperty('id', '')); - - /** - * TODO: - * Moving forward, we should try to use our dependency injection container - * to inject the proof for token. - * This way we will have one source of truth for the proof for token. - */ - $proofForToken = new Token(); - $proofForToken->setHash(new Sha()); - - if ( - empty($user->getId()) // Check a document has been found in the DB - || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) // Validate user has valid login token - ) { - // cookie not valid - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.'); - } - - $roles = $user->getRoles($database->getAuthorization()); - - $authorization = $realtime->connections[$connection]['authorization'] ?? null; - $projectId = $realtime->connections[$connection]['projectId'] ?? null; - // Capture the pre-auth userId so we can rebind any account channels - // that were stored under it (e.g. guest who subscribed to `account` - // and now authenticates). unsubscribe() below clears the connection - // entry, so we must read it first. - $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; - - $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); - $meta = $realtime->getSubscriptionMetadata($connection); - - $realtime->unsubscribe($connection); - - if (!empty($projectId)) { - foreach ($meta as $subscriptionId => $subscription) { - $queries = Query::parseQueries($subscription['queries'] ?? []); - $channels = Realtime::rebindAccountChannels( - $subscription['channels'] ?? [], - $previousUserId, - $user->getId() - ); - - $realtime->subscribe( - $projectId, - $connection, - $subscriptionId, - $roles, - $channels, - $queries, - $user->getId() - ); - } - } - - if ($authorization !== null) { - $realtime->connections[$connection]['authorization'] = $authorization; - } - - $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); - $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; - if ($subscriptionDelta !== 0) { - $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); - } - - $user = $response->output($user, Response::MODEL_ACCOUNT); - - $authResponsePayloadJson = json_encode([ - 'type' => 'response', - 'data' => [ - 'to' => 'authentication', - 'success' => true, - 'user' => $user - ] - ]); - - $server->send([$connection], $authResponsePayloadJson); - $outboundBytes += \strlen($authResponsePayloadJson); - - if ($project !== null && !$project->isEmpty()) { - $authOutboundBytes = \strlen($authResponsePayloadJson); - - if ($authOutboundBytes > 0) { - triggerStats([ - METRIC_REALTIME_OUTBOUND => $authOutboundBytes, - ], $project->getId()); - } - } - - 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 = []; - $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); - 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()); - } - - $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); - - $parsedPayloads[] = [ - 'subscriptionId' => $subscriptionId, - 'channels' => $payload['channels'], - 'convertedChannels' => $convertedChannels, - 'queries' => $convertedQueries, - ]; - } - - foreach ($parsedPayloads as $parsedPayload) { - $subscriptionId = $parsedPayload['subscriptionId']; - $channels = $parsedPayload['convertedChannels']; - $queries = $parsedPayload['queries']; - $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); - } - $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); - $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; - $subscriptionsRequested = \count($parsedPayloads); - if ($subscriptionDelta !== 0) { - $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); - } - - $responsePayload = json_encode([ - 'type' => 'response', - 'data' => [ - 'to' => 'subscribe', - 'success' => true, - 'subscriptions' => \array_map(function (array $parsedPayload) { - return [ - 'subscriptionId' => $parsedPayload['subscriptionId'], - 'channels' => $parsedPayload['convertedChannels'], - 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), - ]; - }, $parsedPayloads), - ] - ]); - - $server->send([$connection], $responsePayload); - $outboundBytes += \strlen($responsePayload); - - if ($project !== null && !$project->isEmpty()) { - $subscribeOutboundBytes = \strlen($responsePayload); - - if ($subscribeOutboundBytes > 0) { - triggerStats([ - METRIC_REALTIME_OUTBOUND => $subscribeOutboundBytes, - ], $project->getId()); - } - } - - break; - - case 'unsubscribe': - if (!\is_array($message['data']) || !\array_is_list($message['data'])) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); - } - - $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); - - // Validate every payload before executing any removal so an invalid entry - // later in the batch does not leave earlier entries half-applied on the server. - $validatedIds = []; - foreach ($message['data'] as $payload) { - if ( - !\is_array($payload) - || !\array_key_exists('subscriptionId', $payload) - || !\is_string($payload['subscriptionId']) - || $payload['subscriptionId'] === '' - ) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.'); - } - $validatedIds[] = $payload['subscriptionId']; - } - - $unsubscribeResults = []; - foreach ($validatedIds as $subscriptionId) { - $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId); - $unsubscribeResults[] = [ - 'subscriptionId' => $subscriptionId, - 'removed' => $wasRemoved, - ]; - } - $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); - $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; - $subscriptionsRequested = \count($validatedIds); - $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'])); - if ($subscriptionDelta !== 0) { - $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); - } - - $unsubscribeResponsePayload = json_encode([ - 'type' => 'response', - 'data' => [ - 'to' => 'unsubscribe', - 'success' => true, - 'subscriptions' => $unsubscribeResults, - ], - ]); - - $server->send([$connection], $unsubscribeResponsePayload); - $outboundBytes += \strlen($unsubscribeResponsePayload); - - if ($project !== null && !$project->isEmpty()) { - $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); - - if ($unsubscribeOutboundBytes > 0) { - triggerStats([ - METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes, - ], $project->getId()); - } - } - - break; - - default: - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); - } $success = true; } catch (Throwable $th) { + Span::error($th); + + // Convert known Utopia DB exceptions to AppwriteException so isPublishable() + // suppresses expected client errors (permission denied, query timeout) from Sentry. + if ($th instanceof AuthorizationException) { + $th = new AppwriteException(AppwriteException::USER_UNAUTHORIZED, previous: $th); + } elseif ($th instanceof TimeoutException) { + $th = new AppwriteException(AppwriteException::DATABASE_TIMEOUT, previous: $th); + } + logError($th, 'realtimeMessage', project: $project, authorization: $authorization); $code = $th->getCode(); if (!is_int($code)) { @@ -1349,14 +1244,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if ($th->getCode() === 1008) { $server->close($connection, $th->getCode()); } - Span::error($th); } finally { Span::add('realtime.success', $success); Span::add('realtime.response_code', $responseCode); - Span::add('realtime.subscription_delta', $subscriptionDelta); - Span::add('realtime.subscriptions_requested', $subscriptionsRequested); - Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); - Span::add('realtime.subscribe.subscriptions_count', $subscriptionsRequested); Span::add('realtime.outbound_bytes', $outboundBytes); Span::add('project.id', $project?->getId() ?? $projectId); Span::add('user.id', $realtime->connections[$connection]['userId'] ?? null); @@ -1365,7 +1255,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } }); -$server->onClose(function (int $connection) use ($realtime, $stats, $register) { +$server->onClose(function (int $connection) use ($realtime, $stats, $register, $container, $presenceState) { $projectId = null; $userId = null; $subscriptionsBeforeClose = 0; @@ -1390,6 +1280,100 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { } $projectId = $realtime->connections[$connection]['projectId']; + /** @var array $presencesById */ + $presencesById = $realtime->connections[$connection]['presences'] ?? []; + + if ( + !empty($presencesById) + && $projectId !== 'console' + ) { + go(function () use ($presencesById, $projectId, $userId, $container, $presenceState): void { + // Fresh span: the parent realtime.close span finishes before this coroutine + Span::init('realtime.close.presenceCleanup'); + Span::add('realtime.projectId', $projectId); + Span::add('realtime.presenceCount', \count($presencesById)); + + try { + $dbForPlatform = getConsoleDB(); + $project = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + + if ($project->isEmpty()) { + return; + } + + $presenceIds = \array_keys($presencesById); + $presences = \array_values($presencesById); + $dbForProject = getProjectDB($project); + + $user = new User([]); + if (!empty($userId)) { + try { + $fetched = $dbForProject->getAuthorization()->skip( + fn () => $dbForProject->getDocument('users', $userId) + ); + if (!$fetched->isEmpty()) { + $user = new User($fetched->getArrayCopy()); + } + } catch (Throwable) { + // Fall back to empty User if lookup fails. + } + } + + /** @var UsagePublisher $publisherForUsage */ + $publisherForUsage = $container->get('publisherForUsage'); + + /** @var array $deletedIds */ + $deletedIds = []; + try { + $deletionCount = $dbForProject->getAuthorization()->skip( + function () use ($dbForProject, $presenceIds, &$deletedIds): int { + return $dbForProject->deleteDocuments( + 'presenceLogs', + [Query::equal('$id', $presenceIds)], + onNext: function (Document $deleted) use (&$deletedIds): void { + $deletedIds[$deleted->getId()] = true; + }, + ); + } + ); + $presenceState->triggerUsage($publisherForUsage, $project, -$deletionCount); + } catch (Throwable $th) { + Span::error($th); + logError($th, 'realtimeOnClosePresenceDeletion', tags: [ + 'projectId' => $projectId, + 'presences' => \count($presences) + ]); + } + + $queueForEvents = getQueueForEvents(); + $queueForRealtime = getQueueForRealtime(); + foreach ($presences as $presence) { + if (!isset($deletedIds[$presence->getId()])) { + continue; + } + try { + $presenceState->triggerEvent( + $queueForEvents, + $queueForRealtime, + $project, + $user, + 'presences.[presenceId].delete', + $presence, + ); + } catch (Throwable) { + // Swallow errors to avoid breaking disconnect cleanup + } + } + } catch (Throwable $th) { + Span::error($th); + logError($th, 'realtimeOnClosePresenceCleanup', tags: [ + 'projectId' => $projectId, + ]); + } finally { + Span::current()?->finish(); + } + }); + } triggerStats([ METRIC_REALTIME_CONNECTIONS => -1, diff --git a/composer.json b/composer.json index 400e3c1822..34a0238b7a 100644 --- a/composer.json +++ b/composer.json @@ -56,18 +56,18 @@ "utopia-php/analytics": "0.15.*", "utopia-php/audit": "2.3.*", "utopia-php/auth": "0.5.*", - "utopia-php/cache": "^2.1", + "utopia-php/cache": "^3.0", "utopia-php/cli": "0.23.*", "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", "utopia-php/database": "5.*", "utopia-php/detector": "0.2.*", - "utopia-php/domains": "2.*", + "utopia-php/domains": "^2.1", "utopia-php/emails": "0.7.*", "utopia-php/dns": "1.7.*", "utopia-php/dsn": "0.2.1", - "utopia-php/http": "^2.0@RC", + "utopia-php/http": "2.0.0-rc1", "utopia-php/fetch": "^1.1", "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", diff --git a/composer.lock b/composer.lock index 66d8f62925..a0a687d8ae 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "035685d1335039f13e16d0532c874b21", + "content-hash": "597066d71be48add0c649828d820a505", "packages": [ { "name": "adhocore/jwt", @@ -3615,16 +3615,16 @@ }, { "name": "utopia-php/cache", - "version": "2.1.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e" + "reference": "086687d7ae23dd1dae67b943161e8cef143539e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e", - "reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/086687d7ae23dd1dae67b943161e8cef143539e1", + "reference": "086687d7ae23dd1dae67b943161e8cef143539e1", "shasum": "" }, "require": { @@ -3663,9 +3663,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/2.1.0" + "source": "https://github.com/utopia-php/cache/tree/3.0.2" }, - "time": "2026-05-12T15:03:23+00:00" + "time": "2026-05-19T22:38:16+00:00" }, { "name": "utopia-php/circuit-breaker", @@ -3923,16 +3923,16 @@ }, { "name": "utopia-php/database", - "version": "5.8.0", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696" + "reference": "477bae83e27631f78c159f45b0441c0c7dc69050" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696", - "reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696", + "url": "https://api.github.com/repos/utopia-php/database/zipball/477bae83e27631f78c159f45b0441c0c7dc69050", + "reference": "477bae83e27631f78c159f45b0441c0c7dc69050", "shasum": "" }, "require": { @@ -3941,7 +3941,7 @@ "ext-pdo": "*", "ext-redis": "*", "php": ">=8.4", - "utopia-php/cache": "^2.0", + "utopia-php/cache": "^3.0", "utopia-php/console": "0.1.*", "utopia-php/mongo": "1.*", "utopia-php/pools": "1.*", @@ -3977,9 +3977,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.8.0" + "source": "https://github.com/utopia-php/database/tree/5.9.0" }, - "time": "2026-05-12T12:52:44+00:00" + "time": "2026-05-17T15:57:21+00:00" }, { "name": "utopia-php/detector", @@ -4079,16 +4079,16 @@ }, { "name": "utopia-php/dns", - "version": "1.7.0", + "version": "1.7.2", "source": { "type": "git", "url": "https://github.com/utopia-php/dns.git", - "reference": "90bf1bc4a51ceca93590d09e7365317b28d1eb89" + "reference": "5225f52a82d4128e69ad17c2a81fcfea6aa00ae1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/dns/zipball/90bf1bc4a51ceca93590d09e7365317b28d1eb89", - "reference": "90bf1bc4a51ceca93590d09e7365317b28d1eb89", + "url": "https://api.github.com/repos/utopia-php/dns/zipball/5225f52a82d4128e69ad17c2a81fcfea6aa00ae1", + "reference": "5225f52a82d4128e69ad17c2a81fcfea6aa00ae1", "shasum": "" }, "require": { @@ -4099,9 +4099,9 @@ "utopia-php/validators": "0.*" }, "require-dev": { - "laravel/pint": "1.25.*", + "laravel/pint": "1.29.*", "phpstan/phpstan": "2.0.*", - "phpunit/phpunit": "12.4.*", + "phpunit/phpunit": "12.5.*", "swoole/ide-helper": "5.1.8" }, "type": "library", @@ -4130,27 +4130,27 @@ ], "support": { "issues": "https://github.com/utopia-php/dns/issues", - "source": "https://github.com/utopia-php/dns/tree/1.7.0" + "source": "https://github.com/utopia-php/dns/tree/1.7.2" }, - "time": "2026-05-13T07:11:31+00:00" + "time": "2026-05-20T04:49:11+00:00" }, { "name": "utopia-php/domains", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "7f76390998359ef67fcea168f614cbd63a4001e8" + "reference": "1b1fea8674e8712e0344d3abb5a7acd558dede50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/7f76390998359ef67fcea168f614cbd63a4001e8", - "reference": "7f76390998359ef67fcea168f614cbd63a4001e8", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/1b1fea8674e8712e0344d3abb5a7acd558dede50", + "reference": "1b1fea8674e8712e0344d3abb5a7acd558dede50", "shasum": "" }, "require": { - "php": ">=8.2", - "utopia-php/cache": "^2.0", + "php": ">=8.3", + "utopia-php/cache": "^3.0", "utopia-php/validators": "0.*" }, "require-dev": { @@ -4192,9 +4192,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/2.0.0" + "source": "https://github.com/utopia-php/domains/tree/2.1.0" }, - "time": "2026-05-12T12:52:53+00:00" + "time": "2026-05-14T14:33:46+00:00" }, { "name": "utopia-php/dsn", @@ -4245,16 +4245,16 @@ }, { "name": "utopia-php/emails", - "version": "0.7.0", + "version": "0.7.1", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107" + "reference": "a5f1d111e5023918731f2de96d348f5b6a0de143" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/115e24aa908e2b1f06c7ff3b94434a0bdbed9107", - "reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/a5f1d111e5023918731f2de96d348f5b6a0de143", + "reference": "a5f1d111e5023918731f2de96d348f5b6a0de143", "shasum": "" }, "require": { @@ -4300,9 +4300,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.7.0" + "source": "https://github.com/utopia-php/emails/tree/0.7.1" }, - "time": "2026-05-13T05:01:26+00:00" + "time": "2026-05-20T13:05:30+00:00" }, { "name": "utopia-php/fetch", @@ -4606,16 +4606,16 @@ }, { "name": "utopia-php/messaging", - "version": "0.22.2", + "version": "0.22.3", "source": { "type": "git", "url": "https://github.com/utopia-php/messaging.git", - "reference": "f99feceab575243f3a86ee2e90cd1a6407805def" + "reference": "67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/messaging/zipball/f99feceab575243f3a86ee2e90cd1a6407805def", - "reference": "f99feceab575243f3a86ee2e90cd1a6407805def", + "url": "https://api.github.com/repos/utopia-php/messaging/zipball/67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e", + "reference": "67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e", "shasum": "" }, "require": { @@ -4651,9 +4651,9 @@ ], "support": { "issues": "https://github.com/utopia-php/messaging/issues", - "source": "https://github.com/utopia-php/messaging/tree/0.22.2" + "source": "https://github.com/utopia-php/messaging/tree/0.22.3" }, - "time": "2026-05-14T08:51:26+00:00" + "time": "2026-05-19T05:31:20+00:00" }, { "name": "utopia-php/migration", @@ -5400,22 +5400,22 @@ }, { "name": "utopia-php/vcs", - "version": "4.1.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "2850dbe975ee69b9466ee6df385fe1679394ce78" + "reference": "49d7751f0ae94634b00057177d9823928f6777c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/2850dbe975ee69b9466ee6df385fe1679394ce78", - "reference": "2850dbe975ee69b9466ee6df385fe1679394ce78", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/49d7751f0ae94634b00057177d9823928f6777c6", + "reference": "49d7751f0ae94634b00057177d9823928f6777c6", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.2", - "utopia-php/cache": "^2.0", + "utopia-php/cache": "^3.0", "utopia-php/fetch": "^1.1" }, "require-dev": { @@ -5443,9 +5443,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/4.1.0" + "source": "https://github.com/utopia-php/vcs/tree/4.2.0" }, - "time": "2026-05-14T10:04:10+00:00" + "time": "2026-05-17T15:58:27+00:00" }, { "name": "utopia-php/websocket", @@ -5638,16 +5638,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.29.5", + "version": "1.31.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620" + "reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e670edcdfb9ffcec36125b1eb3e4473dce30b620", - "reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5699f6da951aef9378fabdcf12f40a9a54fb3128", + "reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128", "shasum": "" }, "require": { @@ -5656,7 +5656,7 @@ "ext-mbstring": "*", "matthiasmullie/minify": "1.3.*", "php": ">=8.3", - "twig/twig": "3.14.*" + "twig/twig": "3.26.*" }, "require-dev": { "brianium/paratest": "7.*", @@ -5683,9 +5683,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.29.5" + "source": "https://github.com/appwrite/sdk-generator/tree/1.31.1" }, - "time": "2026-05-15T06:49:05+00:00" + "time": "2026-05-20T22:22:59+00:00" }, { "name": "brianium/paratest", @@ -6394,11 +6394,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.1.55", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", + "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", "shasum": "" }, "require": { @@ -6443,7 +6443,7 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-05-18T11:57:34+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6882,23 +6882,23 @@ }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -6927,7 +6927,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -6947,7 +6947,7 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", @@ -7244,25 +7244,25 @@ }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -7310,7 +7310,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -7330,7 +7330,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", @@ -7408,24 +7408,24 @@ }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -7454,15 +7454,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -7656,23 +7668,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -7701,7 +7713,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -7721,7 +7733,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -8201,86 +8213,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php81", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, { "name": "symfony/process", "version": "v8.0.11", @@ -8537,26 +8469,27 @@ }, { "name": "twig/twig", - "version": "v3.14.2", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a" + "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a", - "reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc", + "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc", "shasum": "" }, "require": { - "php": ">=8.0.2", + "php": ">=8.1.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-mbstring": "^1.3", - "symfony/polyfill-php81": "^1.29" + "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -8600,7 +8533,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.14.2" + "source": "https://github.com/twigphp/Twig/tree/v3.26.0" }, "funding": [ { @@ -8612,14 +8545,12 @@ "type": "tidelift" } ], - "time": "2024-11-07T12:36:22+00:00" + "time": "2026-05-20T07:31:59+00:00" } ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "utopia-php/http": 5 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/docs/references/presences/delete.md b/docs/references/presences/delete.md new file mode 100644 index 0000000000..70220709c8 --- /dev/null +++ b/docs/references/presences/delete.md @@ -0,0 +1 @@ +Delete a presence log by its unique ID. diff --git a/docs/references/presences/get-usage.md b/docs/references/presences/get-usage.md new file mode 100644 index 0000000000..efbf31ef58 --- /dev/null +++ b/docs/references/presences/get-usage.md @@ -0,0 +1 @@ +Get presence usage metrics, including the current total of online users and historical online user counts for the selected time range. diff --git a/docs/references/presences/get.md b/docs/references/presences/get.md new file mode 100644 index 0000000000..f5f4a82489 --- /dev/null +++ b/docs/references/presences/get.md @@ -0,0 +1 @@ +Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found. diff --git a/docs/references/presences/list.md b/docs/references/presences/list.md new file mode 100644 index 0000000000..b00285b361 --- /dev/null +++ b/docs/references/presences/list.md @@ -0,0 +1 @@ +List presence logs. Expired entries are filtered out automatically. diff --git a/docs/references/presences/update.md b/docs/references/presences/update.md new file mode 100644 index 0000000000..af642d3307 --- /dev/null +++ b/docs/references/presences/update.md @@ -0,0 +1 @@ +Update a presence log by its unique ID. Using the patch method you can pass only specific fields that will get updated. diff --git a/docs/references/presences/upsert.md b/docs/references/presences/upsert.md new file mode 100644 index 0000000000..873dc793f5 --- /dev/null +++ b/docs/references/presences/upsert.md @@ -0,0 +1 @@ +Create or update a presence log by its user ID. diff --git a/docs/sdks/unity/GETTING_STARTED.md b/docs/sdks/unity/GETTING_STARTED.md new file mode 100644 index 0000000000..7e1f37879c --- /dev/null +++ b/docs/sdks/unity/GETTING_STARTED.md @@ -0,0 +1,116 @@ +## Getting Started + +Before you begin, create an Appwrite project and add a Unity platform in your Appwrite Console. + +This SDK requires the following Unity packages and libraries: + +- [**UniTask**](https://github.com/Cysharp/UniTask): For async/await support in Unity. +- [**NativeWebSocket**](https://github.com/endel/NativeWebSocket): For WebSocket realtime subscriptions. +- **System.Text.Json**: For JSON serialization, provided as a DLL in the project. + +After installing the SDK, open **Appwrite → Setup Assistant** in Unity and install the required dependencies. + +### Configure the SDK + +Create an Appwrite configuration using the **QuickStart** window in the **Appwrite Setup Assistant**, or through **Appwrite → Create Configuration**. + +### Using AppwriteManager + +```csharp +[SerializeField] private AppwriteConfig config; +private AppwriteManager _manager; + +private async UniTask ExampleWithManager() +{ + _manager = AppwriteManager.Instance ?? new GameObject("AppwriteManager").AddComponent(); + _manager.SetConfig(config); + + var success = await _manager.Initialize(needRealtime: true); + if (!success) + { + Debug.LogError("Failed to initialize AppwriteManager"); + return; + } + + var client = _manager.Client; + var pingResult = await client.Ping(); + Debug.Log($"Ping result: {pingResult}"); + + var realtime = _manager.Realtime; + var subscription = realtime.Subscribe( + new[] { "databases.*.collections.*.documents" }, + response => + { + var eventName = response.Events != null && response.Events.Length > 0 + ? response.Events[0] + : "unknown"; + + Debug.Log($"Realtime event: {eventName}"); + } + ); + + // Keep a reference to close the subscription when your MonoBehaviour is destroyed. + // subscription.Close(); +} +``` + +### Using Client directly + +```csharp +private async UniTask ExampleWithDirectClient() +{ + var client = Client.From( + projectId: "", + endpoint: "https://.cloud.appwrite.io/v1", + endpointRealtime: "wss://.cloud.appwrite.io/v1"); + + var pingResult = await client.Ping(); + Debug.Log($"Direct client ping: {pingResult}"); +} +``` + +You can also create authenticated clients with `Client.FromSession`, `Client.FromDevKey`, or `Client.FromImpersonation` when those authentication flows are needed. + +### Error handling + +```csharp +try +{ + var result = await client.Ping(); +} +catch (AppwriteException ex) +{ + Debug.LogError($"Appwrite Error: {ex.Message}"); + Debug.LogError($"Status Code: {ex.Code}"); + Debug.LogError($"Response: {ex.Response}"); +} +``` + +## Preparing Models for Databases API + +When working with the Databases API in Unity, models should be prepared for serialization using the System.Text.Json library. System.Text.Json uses CLR property names by default unless a naming policy is configured. If your project or SDK configuration serializes property names differently from your Appwrite collection attributes, this can cause errors due to mismatches between serialized property names and actual attribute names in your collection. + +To avoid this, add the `JsonPropertyName` attribute to each property in your model class to match the attribute name in Appwrite: + +```csharp +using System.Text.Json.Serialization; + +public class TestModel +{ + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("release_date")] + public System.DateTime ReleaseDate { get; set; } +} +``` + +The `JsonPropertyName` attribute ensures your data object is serialized with the correct attribute names for Appwrite databases. + +### Learn more +You can use the following resources to learn more and get help: + +- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-client) +- 📜 [Appwrite Docs](https://appwrite.io/docs) +- 💬 [Discord Community](https://appwrite.io/discord) +- 🧰 [Appwrite SDK Generator](https://github.com/appwrite/sdk-generator) diff --git a/src/Appwrite/Event/Message/StatsResources.php b/src/Appwrite/Event/Message/StatsResources.php index 584cbc137a..dfca818130 100644 --- a/src/Appwrite/Event/Message/StatsResources.php +++ b/src/Appwrite/Event/Message/StatsResources.php @@ -6,8 +6,13 @@ use Utopia\Database\Document; final class StatsResources extends Base { + /** + * @param Document $project + * @param array $gauges + */ public function __construct( public readonly Document $project, + public readonly array $gauges = [], ) { } @@ -15,6 +20,7 @@ final class StatsResources extends Base { return [ 'project' => $this->project->getArrayCopy(), + 'gauges' => $this->gauges, ]; } @@ -22,6 +28,7 @@ final class StatsResources extends Base { return new self( project: new Document($data['project'] ?? []), + gauges: $data['gauges'] ?? [], ); } } diff --git a/src/Appwrite/Event/StatsResources.php b/src/Appwrite/Event/StatsResources.php index 07f23feda8..42259f76b9 100644 --- a/src/Appwrite/Event/StatsResources.php +++ b/src/Appwrite/Event/StatsResources.php @@ -9,6 +9,18 @@ class StatsResources extends Event { protected bool $critical = false; + /** + * Pre-computed gauge metric snapshots to write to the stats collection. When non-empty, + * the StatsResources worker takes the fast path: it writes these directly via + * upsertDocuments (replace semantics) and skips the standard counting work. + * + * Each entry is a tuple of (metric key, value). The worker writes one stats document per + * (metric, period) tuple using the project's region. + * + * @var array + */ + protected array $gauges = []; + public function __construct(protected Publisher $publisher) { parent::__construct($publisher); @@ -18,6 +30,35 @@ class StatsResources extends Event ->setClass(System::getEnv('_APP_STATS_RESOURCES_CLASS_NAME', Event::STATS_RESOURCES_CLASS_NAME)); } + /** + * Set the full set of pre-computed gauge metrics for this message. Replaces any + * previously-set gauges. + * + * @param array $gauges + */ + public function setGauges(array $gauges): self + { + $this->gauges = $gauges; + return $this; + } + + /** + * Append a single pre-computed gauge metric to this message. + */ + public function addGauge(string $metric, int $value): self + { + $this->gauges[] = ['metric' => $metric, 'value' => $value]; + return $this; + } + + /** + * @return array + */ + public function getGauges(): array + { + return $this->gauges; + } + /** * Prepare the payload for the usage event. * @@ -26,7 +67,8 @@ class StatsResources extends Event protected function preparePayload(): array { return [ - 'project' => $this->project + 'project' => $this->project, + 'gauges' => $this->gauges, ]; } } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index a0553d00b8..6506127c59 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -202,6 +202,10 @@ class Exception extends \Exception /** Log */ public const string LOG_NOT_FOUND = 'log_not_found'; + /** Presence */ + public const string PRESENCE_NOT_FOUND = 'presence_not_found'; + public const string PRESENCE_ALREADY_EXISTS = 'presence_already_exists'; + /** Databases */ public const string DATABASE_NOT_FOUND = 'database_not_found'; public const string DATABASE_ALREADY_EXISTS = 'database_already_exists'; diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 6e8c30b6ec..4471ab53a7 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -345,6 +345,7 @@ class Resolvers $original = $utopia->getRoute(); try { $request = clone $request; + $request->addHeader('x-appwrite-source', 'graphql'); // Drop json content type so post args are used directly. if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index c4cd2c08d5..dbb2c826bd 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -34,6 +34,7 @@ class Realtime extends MessagingAdapter 'account', 'teams', 'memberships', + 'presences' ]; /** @@ -44,6 +45,7 @@ class Realtime extends MessagingAdapter * 'roles' -> [ROLE_x, ROLE_Y] * 'userId' -> [USER_ID] * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] + * 'presences' -> [PRESENCE_ID_1, PRESENCE_ID_2, ...] */ public array $connections = []; @@ -146,6 +148,7 @@ class Realtime extends MessagingAdapter 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), 'userId' => $userId ?? ($existing['userId'] ?? ''), 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), + 'presences' => $this->connections[$identifier]['presences'] ?? [] ]; if (\array_key_exists('authorization', $existing)) { @@ -202,6 +205,74 @@ class Realtime extends MessagingAdapter return $subscriptions; } + /** + * Dedup delete presence triggers. + * Scenario: when client is connected to realtime and a delete call is made throught rest. + * If not dedupe then two delete events will get triggered. So remove the presenceIds + * + * @param string $projectId + * @param string $presenceId + * @return int Number of connections whose presences map was updated. + */ + public function removePresenceFromConnections(string $projectId, string $presenceId): int + { + if ($projectId === '' || $presenceId === '') { + return 0; + } + + $removed = 0; + foreach ($this->connections as $connectionId => $connection) { + if (($connection['projectId'] ?? null) !== $projectId) { + continue; + } + if (!isset($connection['presences'][$presenceId])) { + continue; + } + unset($this->connections[$connectionId]['presences'][$presenceId]); + $removed++; + } + + return $removed; + } + + /** + * Returns the presence ID carried by a `presences.{id}.delete` event payload, + * or null when the event is not a presence delete. + * + * @param array $event Decoded pubsub payload produced by self::send(). + * @return string|null + */ + public static function extractDeletedPresenceId(array $event): ?string + { + $events = $event['data']['events'] ?? []; + if (!\is_array($events)) { + return null; + } + + $isPresenceDelete = false; + foreach ($events as $eventName) { + if ( + \is_string($eventName) + && \str_starts_with($eventName, 'presences.') + && \str_ends_with($eventName, '.delete') + ) { + $isPresenceDelete = true; + break; + } + } + + if (!$isPresenceDelete) { + return null; + } + + $presenceId = $event['data']['payload']['$id'] ?? null; + if (!\is_string($presenceId) || $presenceId === '') { + return null; + } + + return $presenceId; + } + /** * Removes all subscriptions for a connection. * @@ -789,6 +860,11 @@ class Realtime extends MessagingAdapter } $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; + case 'presences': + $channels[] = 'presences'; + $channels[] = 'presences.' . $parts[1]; + $roles = $payload->getRead(); + break; } // Action is the last segment for plain CRUD events (e.g. `documents.X.create`), diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php index a2d9d7907b..0aa67cb74e 100644 --- a/src/Appwrite/Migration/Version/V24.php +++ b/src/Appwrite/Migration/Version/V24.php @@ -55,6 +55,9 @@ class V24 extends Migration if ($this->project->getSequence() != 'console') { Console::info('Migrating Databases'); $this->migrateDatabases(); + + Console::info('Creating presence logs collection'); + $this->createPresenceLogsCollection(); } Console::info('Migrating Buckets'); @@ -330,6 +333,30 @@ class V24 extends Migration }); } + /** + * Ensure the presenceLogs collection exists for project databases. + * + * @return void + * @throws Throwable + */ + private function createPresenceLogsCollection(): void + { + $collectionId = 'presenceLogs'; + + try { + Console::info("Ensuring collection \"{$collectionId}\" exists for project \"{$this->project->getId()}\"."); + $this->dbForProject->purgeCachedCollection($collectionId); + $this->dbForProject->purgeCachedDocument(Database::METADATA, $collectionId); + + $this->createCollection($collectionId); + } catch (Throwable $th) { + Console::warning("Failed to create collection \"{$collectionId}\": {$th->getMessage()}"); + + // Re-throw so the migration fails fast and doesn't leave the system in a partially migrated state. + throw $th; + } + } + /** * Migrate all Bucket tables * diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index a9cd1a8e2f..310d59615d 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -11,6 +11,7 @@ use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; use Appwrite\Platform\Modules\Migrations; +use Appwrite\Platform\Modules\Presences; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -31,6 +32,7 @@ class Appwrite extends Platform $this->addModule(new Avatars\Module()); $this->addModule(new Databases\Module()); $this->addModule(new Projects\Module()); + $this->addModule(new Presences\Module()); $this->addModule(new Functions\Module()); $this->addModule(new Health\Module()); $this->addModule(new Sites\Module()); diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/Get.php b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/Get.php index 4796d5851f..d605cacaef 100644 --- a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/Get.php +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/Get.php @@ -36,7 +36,7 @@ class Get extends Action group: 'insights', name: 'getInsight', description: '/docs/references/advisor/get-insight.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php index 64d3676c08..6cf779bfb9 100644 --- a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php @@ -42,7 +42,7 @@ class XList extends Action group: 'insights', name: 'listInsights', description: '/docs/references/advisor/list-insights.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php index 78885a7c5d..e912161e26 100644 --- a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php @@ -37,7 +37,7 @@ class Get extends Action group: 'reports', name: 'getReport', description: '/docs/references/advisor/get-report.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php index c5debb7f68..1440d09142 100644 --- a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php +++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php @@ -41,7 +41,7 @@ class XList extends Action group: 'reports', name: 'listReports', description: '/docs/references/advisor/list-reports.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index c43c0fc4bf..f33bfa938a 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -72,7 +72,7 @@ class Get extends Action ->param('userAgent', '', new Text(512), 'Custom user agent string. Defaults to browser default.', true, example: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15') ->param('fullpage', false, new Boolean(true), 'Capture full page scroll. Pass 0 for viewport only, or 1 for full page. Defaults to 0.', true, example: 'true') ->param('locale', '', new Text(10), 'Browser locale (e.g., "en-US", "fr-FR"). Defaults to browser default.', true, example: 'en-US') - ->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'america/new_york') + ->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'America/New_York') ->param('latitude', 0, new Range(-90, 90, Range::TYPE_FLOAT), 'Geolocation latitude. Pass a number between -90 to 90. Defaults to 0.', true, example: '37.7749') ->param('longitude', 0, new Range(-180, 180, Range::TYPE_FLOAT), 'Geolocation longitude. Pass a number between -180 to 180. Defaults to 0.', true, example: '-122.4194') ->param('accuracy', 0, new Range(0, 100000, Range::TYPE_FLOAT), 'Geolocation accuracy in meters. Pass a number between 0 to 100000. Defaults to 0.', true, example: '100') 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 3a49d6c665..fdcbced6f3 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 @@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Timeout; @@ -118,7 +119,14 @@ class XList extends Action $documentId = $cursor->getValue(); - $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + try { + $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + } catch (NotFoundException) { + // The collection metadata document exists but the backing store (e.g. a + // dedicated DocumentsDB shard) has no table for it. Treat this as a + // not-found on the collection so the caller sees a 404 instead of a 500. + throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); + } if ($cursorDocument->isEmpty()) { $type = ucfirst($this->getContext()); @@ -199,6 +207,11 @@ class XList extends Action $documents = $find(); $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } + } catch (NotFoundException) { + // The collection metadata document exists but the backing store (e.g. a + // dedicated DocumentsDB shard) has no table for it. Treat this as a + // not-found on the collection so the caller sees a 404 instead of a 500. + throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php index bf7c3c4889..8d717eb9ab 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php @@ -8,12 +8,10 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; -use Utopia\Cache\Adapter\Pool as CachePool; -use Utopia\Config\Config; +use Utopia\Cache\Cache; use Utopia\Database\Document; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Pools\Group; class Get extends Action { @@ -47,45 +45,32 @@ class Get extends Action contentType: ContentType::JSON )) ->inject('response') - ->inject('pools') + ->inject('cache') ->callback($this->action(...)); } - public function action(Response $response, Group $pools): void + public function action(Response $response, Cache $cache): void { $output = []; - $failures = []; - $configs = [ - 'Cache' => Config::getParam('pools-cache'), - ]; + $checkStart = \microtime(true); - foreach ($configs as $key => $config) { - foreach ($config as $cache) { - try { - $adapter = new CachePool($pools->get($cache)); - - $checkStart = \microtime(true); - - if ($adapter->ping()) { - $output[] = new Document([ - 'name' => $key . " ($cache)", - 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) * 1000), - ]); - } else { - $failures[] = $cache; - } - } catch (\Throwable) { - $failures[] = $cache; - } - } + try { + $ok = $cache->ping(); + } catch (\Throwable) { + $ok = false; } - if (!empty($failures)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Cache failure on: ' . \implode(', ', $failures)); + if (!$ok) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Cache failure on: cache'); } + $output[] = new Document([ + 'name' => 'Cache', + 'status' => 'pass', + 'ping' => \round((\microtime(true) - $checkStart) * 1000), + ]); + $response->dynamic(new Document([ 'statuses' => $output, 'total' => \count($output), diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php new file mode 100644 index 0000000000..66ab00c3a7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php @@ -0,0 +1,88 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Delete presence') + ->groups(['api', 'presences']) + ->label('scope', 'presences.write') + ->label('event', 'presences.[presenceId].delete') + ->label('audits.event', 'presence.delete') + ->label('audits.resource', 'presence/{request.presenceId}') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'delete', + desc: 'Delete presence', + description: '/docs/references/presences/delete.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ), + ], + contentType: ContentType::NONE, + )) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('usage') + ->callback($this->action(...)); + } + + public function action(string $presenceId, Response $response, Database $dbForProject, Event $queueForEvents, Context $usage): void + { + $presence = $dbForProject->getDocument('presenceLogs', $presenceId); + + if ($presence->isEmpty()) { + throw new Exception(Exception::PRESENCE_NOT_FOUND); + } + + try { + $dbForProject->deleteDocument('presenceLogs', $presenceId); + } catch (ConflictException) { + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT); + } catch (RestrictedException) { + throw new Exception(Exception::DOCUMENT_DELETE_RESTRICTED); + } + + (new PresenceState())->purgeListCache($dbForProject); + + $usage->addMetric(METRIC_USERS_PRESENCE, -1); + + $queueForEvents + ->setParam('presenceId', $presence->getId()) + ->setPayload($response->output($presence, Response::MODEL_PRESENCE)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php new file mode 100644 index 0000000000..ba6b769f70 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php @@ -0,0 +1,69 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Get presence') + ->groups(['api', 'presences']) + ->label('scope', 'presences.read') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'get', + desc: 'Get presence', + description: '/docs/references/presences/get.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + )) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $presenceId, Response $response, Database $dbForProject): void + { + $presence = $dbForProject->getDocument('presenceLogs', $presenceId); + if ($presence->isEmpty()) { + throw new Exception(Exception::PRESENCE_NOT_FOUND); + } + + $presenceExpiresAt = $presence->getAttribute('expiresAt'); + + if (!empty($presenceExpiresAt) && DateTime::formatTz($presenceExpiresAt) < DateTime::formatTz(DateTime::now())) { + throw new Exception(Exception::PRESENCE_NOT_FOUND); + } + + $response->dynamic($presence, Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php new file mode 100644 index 0000000000..5387d3a91e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php @@ -0,0 +1,206 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Update presence') + ->groups(['api', 'presences']) + ->label('scope', 'presences.write') + ->label('event', 'presences.[presenceId].update') + ->label('audits.event', 'presence.update') + ->label('audits.resource', 'presence/{response.$id}') + ->label('sdk', [ + // Client-side SDK: `userId` is not accepted (session callers can only update their own presence). + new Method( + namespace: 'presences', + group: 'presences', + name: 'update', + desc: 'Update presence', + description: '/docs/references/presences/update.md', + auth: [AuthType::SESSION], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + parameters: [ + new Parameter('presenceId', optional: false), + new Parameter('status', optional: true), + new Parameter('expiresAt', optional: true), + new Parameter('metadata', optional: true), + new Parameter('permissions', optional: true), + new Parameter('purge', optional: true), + ], + ), + // Server-side SDK: `userId` is required when authenticating with API keys/JWT. + new Method( + namespace: 'presences', + group: 'presences', + name: 'updatePresence', + desc: 'Update presence', + description: '/docs/references/presences/update.md', + auth: [AuthType::KEY, AuthType::JWT, AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + parameters: [ + new Parameter('presenceId', optional: false), + new Parameter('userId', optional: false), + new Parameter('status', optional: true), + new Parameter('expiresAt', optional: true), + new Parameter('metadata', optional: true), + new Parameter('permissions', optional: true), + new Parameter('purge', optional: true), + ], + ), + ]) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->param('userId', null, new UID(), 'User ID.', true) + ->param('status', null, new Text(Database::LENGTH_KEY), 'Presence status.', true) + ->param('expiresAt', null, new DatetimeValidator( + new \DateTime(), + (new \DateTime())->modify('+30 days'), + requireDateInFuture: true + ), 'Presence expiry datetime.', true) + ->param('metadata', null, new JSON(), 'Presence metadata object.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('purge', false, new Boolean(true), 'When true, purge cached responses used by list presences endpoint.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $presenceId, + ?string $userId, + ?string $status, + ?string $expiresAt, + ?array $metadata, + ?array $permissions, + bool $purge, + Response $response, + Database $dbForProject, + User $user, + Authorization $authorization, + Event $queueForEvents + ): void { + $presenceState = new PresenceState(); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + + if ($userId && !$isAPIKey && !$isPrivilegedUser) { + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'userId is not allowed for non-API key and non-privileged users'); + } + + $presence = $dbForProject->getDocument('presenceLogs', $presenceId); + + if ($presence->isEmpty()) { + throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId]); + } + + $presenceExpiresAt = $presence->getAttribute('expiresAt'); + if (!empty($presenceExpiresAt) && DateTime::formatTz($presenceExpiresAt) < DateTime::formatTz(DateTime::now())) { + throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId]); + } + + $updateData = []; + + if ($userId !== null) { + $updateData['userId'] = $userId; + $userDoc = $dbForProject->getDocument('users', $userId); + if ($userDoc->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]); + } + $updateData['userInternalId'] = $userDoc->getSequence(); + } + + if ($status !== null) { + $updateData['status'] = $status; + } + + if ($expiresAt !== null) { + $updateData['expiresAt'] = $expiresAt; + } + + if ($metadata !== null) { + $updateData['metadata'] = $metadata; + } + + $updates = new Document($updateData); + + if ($permissions !== null) { + $presenceState->setPermissions($updates, $permissions, $user, $authorization); + } elseif ($userId !== null && $userId !== $presence->getAttribute('userId')) { + $presenceState->setPermissions($updates, null, $user, $authorization, ownerOverride: $userId); + } + + if (empty($updateData) && $permissions === null) { + if ($purge) { + $presenceState->purgeListCache($dbForProject); + } + $response->dynamic($presence, Response::MODEL_PRESENCE); + return; + } + + try { + $presence = $dbForProject->updateDocument('presenceLogs', $presenceId, $updates); + } catch (Duplicate $e) { + throw new Exception(Exception::PRESENCE_ALREADY_EXISTS, params: [$presenceId], previous: $e); + } catch (StructureException $e) { + throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e); + } catch (ConflictException $e) { + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e); + } + + if ($purge) { + $presenceState->purgeListCache($dbForProject); + } + + $queueForEvents->setParam('presenceId', $presence->getId()); + + $response->dynamic($presence, Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php new file mode 100644 index 0000000000..c85cb15f17 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php @@ -0,0 +1,192 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Upsert presence') + ->groups(['api', 'presences']) + ->label('scope', 'presences.write') + ->label('event', 'presences.[presenceId].upsert') + ->label('audits.event', 'presence.upsert') + ->label('audits.resource', 'presence/{response.$id}') + ->label('sdk', [ + // Client-side SDK: `userId` is not accepted (session callers should just upsert their own presence). + new Method( + namespace: 'presences', + group: 'presences', + name: 'upsert', + desc: 'Upsert presence', + description: '/docs/references/presences/upsert.md', + auth: [AuthType::SESSION], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + parameters: [ + new Parameter('presenceId', optional: false), + new Parameter('status', optional: false), + new Parameter('permissions', optional: true), + new Parameter('expiresAt', optional: true), + new Parameter('metadata', optional: true), + ], + ), + // Server-side SDK: `userId` is required when authenticating with API keys/JWT. + new Method( + namespace: 'presences', + group: 'presences', + name: 'upsert', + desc: 'Upsert presence', + description: '/docs/references/presences/upsert.md', + auth: [AuthType::KEY, AuthType::JWT, AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + parameters: [ + new Parameter('presenceId', optional: false), + new Parameter('userId', optional: false), + new Parameter('status', optional: false), + new Parameter('permissions', optional: true), + new Parameter('expiresAt', optional: true), + new Parameter('metadata', optional: true), + ], + ), + ]) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->param('userId', null, new UID(), 'User ID.', true) + ->param('status', '', new Text(Database::LENGTH_KEY), 'Presence status.', false) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('expiresAt', null, new DatetimeValidator( + new \DateTime(), + (new \DateTime())->modify('+30 days'), + requireDateInFuture: true + ), 'Presence expiry datetime.', true) + ->param('metadata', [], new JSON(), 'Presence metadata object.', true) + ->inject('response') + ->inject('request') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->inject('queueForEvents') + ->inject('usage') + ->callback($this->action(...)); + } + + public function action( + string $presenceId, + ?string $userId, + ?string $status, + ?array $permissions, + ?string $expiresAt, + array $metadata, + Response $response, + Request $request, + Database $dbForProject, + User $user, + Authorization $authorization, + Event $queueForEvents, + Context $usage + ): void { + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + if ($userId && !$isAPIKey && !$isPrivilegedUser) { + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, "userId is not allowed for non-API key and non-privileged users"); + } + + if (($isAPIKey || $isPrivilegedUser) && !$userId) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, "userId is required for API key and privileged users"); + } + $userInternalId = null; + $resolvedUserId = $userId; + if (!$isAPIKey && !$isPrivilegedUser) { + $userInternalId = $user->getSequence(); + $resolvedUserId = $user->getId(); + } else { + $fetchedUser = $dbForProject->getDocument('users', $userId); + if ($fetchedUser->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]); + } + + $userInternalId = (string) $fetchedUser->getSequence(); + $resolvedUserId = $fetchedUser->getId(); + } + + if (empty($userInternalId)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to resolve valid user internal ID.'); + } + $isGraphQL = $request->getHeader('x-appwrite-source') === 'graphql'; + + $presenceData = [ + 'userInternalId' => $userInternalId, + 'userId' => $resolvedUserId, + 'status' => $status, + 'source' => $isGraphQL ? 'graphql' : 'rest', + 'expiresAt' => $expiresAt ?? DateTime::addSeconds(new \DateTime(), 15 * 60), + 'metadata' => $metadata, + ]; + + $presenceState = new PresenceState(); + $presenceDocument = new Document($presenceData); + $ownerOverride = $permissions === null && ($isAPIKey || $isPrivilegedUser) + ? $resolvedUserId + : null; + $presenceState->setPermissions( + $presenceDocument, + $permissions, + $user, + $authorization, + ownerOverride: $ownerOverride, + ); + $presence = $presenceState->upsertForUser( + $dbForProject, + $presenceDocument, + $presenceId, + $userInternalId, + fn () => $usage->addMetric(METRIC_USERS_PRESENCE, 1) + ); + $queueForEvents->setParam('presenceId', $presence->getId()); + + $response->dynamic($presence, Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Usage/Get.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Usage/Get.php new file mode 100644 index 0000000000..636010e765 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Usage/Get.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/presences/usage') + ->desc('Get presence usage') + ->groups(['api', 'presences', 'usage']) + ->label('scope', 'presences.read') + ->label('sdk', new Method( + namespace: 'presences', + group: null, + name: 'getUsage', + desc: 'Get presence usage', + description: '/docs/references/presences/get-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USAGE_PRESENCE, + ), + ], + )) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d']), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $range, + Response $response, + Database $dbForProject, + Authorization $authorization + ): void { + $periods = Config::getParam('usage', []); + $days = $periods[$range]; + $metric = METRIC_USERS_PRESENCE; + $stats = [ + 'total' => 0, + 'data' => [], + ]; + $hasTotal = false; + + $authorization->skip(function () use ($dbForProject, $days, $metric, &$stats, &$hasTotal): void { + $result = $dbForProject->findOne('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', ['inf']), + ]); + + $hasTotal = !$result->isEmpty(); + $stats['total'] = $result['value'] ?? 0; + + $results = $dbForProject->find('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', [$days['period']]), + Query::limit($days['limit']), + Query::orderDesc('time'), + ]); + + foreach ($results as $result) { + $stats['data'][$result->getAttribute('time')] = [ + 'value' => $result->getAttribute('value'), + ]; + } + }); + + if (!$hasTotal && !empty($stats['data'])) { + $stats['total'] = \end($stats['data'])['value'] ?? 0; + } + + $format = match ($days['period']) { + '1h' => 'Y-m-d\TH:00:00.000P', + '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), + }; + + $usage = []; + $leap = time() - ($days['limit'] * $days['factor']); + while ($leap < time()) { + $leap += $days['factor']; + $formatDate = date($format, $leap); + $usage[] = [ + 'value' => $stats['data'][$formatDate]['value'] ?? 0, + 'date' => $formatDate, + ]; + } + + $response->dynamic(new Document([ + 'range' => $range, + 'usersOnlineTotal' => $stats['total'], + 'presences' => $usage, + ]), Response::MODEL_USAGE_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php new file mode 100644 index 0000000000..94dca8c4ee --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php @@ -0,0 +1,178 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/presences') + ->desc('List presences') + ->groups(['api', 'presences']) + ->label('scope', 'presences.read') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'list', + desc: 'List presences', + description: '/docs/references/presences/list.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE_LIST, + ), + ], + )) + ->param('queries', [], new PresencesQueries(), 'Array of query strings generated using the Query class provided by the SDK.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(array $queries, bool $includeTotal, int $ttl, Response $response, Database $dbForProject): void + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $presenceId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('presenceLogs', $presenceId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Presence '{$presenceId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $groupedQueries = Query::groupByType($queries); + $filterQueries = $groupedQueries['filters']; + + // should be excluded from the user provided query as user query would be used for caching only + // otherwise cache will always miss due to the datetime now + $expiryFilter = Query::greaterThan('expiresAt', DateTime::now()); + + try { + if ((int)$ttl > 0) { + $presenceState = new PresenceState(); + $roles = $dbForProject->getAuthorization()->getRoles(); + + $documentsCacheHit = false; + $cachedDocuments = $presenceState->getListCacheField( + $dbForProject, + $roles, + $queries, + PresenceState::LIST_CACHE_FIELD_PRESENCES, + $ttl + ); + + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; + } else { + $documents = $dbForProject->find('presenceLogs', [...$queries, $expiryFilter]); + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + $presenceState->setListCacheField( + $dbForProject, + $roles, + $queries, + PresenceState::LIST_CACHE_FIELD_PRESENCES, + $documentsArray + ); + } + + if ($includeTotal) { + $cachedTotal = $presenceState->getListCacheField( + $dbForProject, + $roles, + $filterQueries, + PresenceState::LIST_CACHE_FIELD_TOTAL, + $ttl + ); + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = (int) $cachedTotal; + } else { + $total = $dbForProject->count('presenceLogs', [...$filterQueries, $expiryFilter], APP_LIMIT_COUNT); + $presenceState->setListCacheField( + $dbForProject, + $roles, + $filterQueries, + PresenceState::LIST_CACHE_FIELD_TOTAL, + $total + ); + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + } else { + $documents = $dbForProject->find('presenceLogs', [...$queries, $expiryFilter]); + $total = $includeTotal ? $dbForProject->count('presenceLogs', [...$filterQueries, $expiryFilter], APP_LIMIT_COUNT) : 0; + } + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } catch (StructureException $e) { + throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e); + } catch (RelationshipException $e) { + throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, $e->getMessage(), previous: $e); + } + + $response->dynamic(new Document([ + 'presences' => $documents, + 'total' => $total, + ]), Response::MODEL_PRESENCE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/Module.php b/src/Appwrite/Platform/Modules/Presences/Module.php new file mode 100644 index 0000000000..26be38e58c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/Services/Http.php b/src/Appwrite/Platform/Modules/Presences/Services/Http.php new file mode 100644 index 0000000000..40aafd6610 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/Services/Http.php @@ -0,0 +1,27 @@ +type = Service::TYPE_HTTP; + + $this + ->addAction(UpsertPresence::getName(), new UpsertPresence()) + ->addAction(GetUsage::getName(), new GetUsage()) + ->addAction(GetPresence::getName(), new GetPresence()) + ->addAction(ListPresences::getName(), new ListPresences()) + ->addAction(UpdatePresence::getName(), new UpdatePresence()) + ->addAction(DeletePresence::getName(), new DeletePresence()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 97e723f52c..b99a9db3c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -60,12 +60,12 @@ class Update extends Action )) ->param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true) - ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) - ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) - ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) - ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) - ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) - ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('username', null, new Nullable(new Text(256, 0)), 'SMTP server username. Pass an empty string to clear a previously set value.', optional: true) + ->param('password', null, new Nullable(new Text(256, 0)), 'SMTP server password. Pass an empty string to clear a previously set value. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', null, new Nullable(new Email(allowEmpty: true)), 'Email address shown in inbox as the sender of the email. Pass an empty string to clear a previously set value.', optional: true) + ->param('senderName', null, new Nullable(new Text(256, 0)), 'Name shown in inbox as the sender of the email. Pass an empty string to clear a previously set value.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email(allowEmpty: true)), 'Email used when user replies to the email. Pass an empty string to clear a previously set value.', optional: true) + ->param('replyToName', null, new Nullable(new Text(256, 0)), 'Name used when user replies to the email. Pass an empty string to clear a previously set value.', optional: true) ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) ->inject('response') @@ -95,7 +95,8 @@ class Update extends Action // Fetch current configuration $smtp = $project->getAttribute('smtp', []); - // Apply changes + // Apply changes — null means "not provided, keep existing". + // Empty string explicitly clears a previously-set value. $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; foreach ($keys as $key) { if (!\is_null(${$key})) { @@ -120,7 +121,7 @@ class Update extends Action // Validate when the caller is explicitly enabling or hasn't expressed a preference // (so a credentials-only PATCH can auto-enable). Skip only when the caller is // explicitly keeping/turning SMTP off. - if (\is_null($enabled) || $enabled === true) { + if ((\is_null($enabled) || $enabled === true) && !empty($smtp['senderEmail'] ?? '')) { $mail = new PHPMailer(true); $mail->isSMTP(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index ef93abf683..c9c64ebdfa 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -61,8 +61,8 @@ class Update extends Action ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true) ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true) ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true) - ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) - ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) + ->param('senderEmail', null, new Nullable(new Email(allowEmpty: true)), 'Email of the sender. Pass an empty string to clear a previously set value.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email(allowEmpty: true)), 'Reply to email. Pass an empty string to clear a previously set value.', optional: true) ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) ->inject('response') ->inject('queueForEvents') @@ -99,7 +99,8 @@ class Update extends Action $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? []; - // Apply changes + // Apply changes — null means "not provided, keep existing". + // Empty string explicitly clears a previously-set value. $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject']; foreach ($keys as $key) { if (!\is_null(${$key})) { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a58fc48098..8a3cc65e60 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -6,8 +6,11 @@ use Appwrite\Certificates\Adapter as CertificatesAdapter; use Appwrite\Deletes\Identities; use Appwrite\Deletes\Targets; use Appwrite\Event\Message\Delete as DeleteMessage; +use Appwrite\Event\Message\Usage; use Appwrite\Event\Publisher\Delete as DeletePublisher; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Extend\Exception; +use Appwrite\Usage\Context as UsageContext; use Executor\Executor; use Throwable; use Utopia\Abuse\Adapters\TimeLimit\Database as AbuseDatabase; @@ -69,6 +72,7 @@ class Deletes extends Action ->inject('log') ->inject('publisherForDeletes') ->inject('getAudit') + ->inject('publisherForUsage') ->callback($this->action(...)); } @@ -96,6 +100,7 @@ class Deletes extends Action Log $log, DeletePublisher $publisherForDeletes, callable $getAudit, + UsagePublisher $publisherForUsage, ): void { $payload = $message->getPayload(); @@ -216,6 +221,7 @@ class Deletes extends Action $this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime); $this->deleteExpiredSessions($project, $getProjectDB); $this->deleteExpiredTransactions($project, $getProjectDB); + $this->deleteExpiredPresences($project, $getProjectDB, $publisherForUsage); $this->deleteOldDeployments($publisherForDeletes, $project, $getProjectDB); break; case DELETE_TYPE_REPORT: @@ -1021,6 +1027,7 @@ class Deletes extends Action Query::equal('resourceInternalId', [$resourceInternalId]), Query::equal('resourceType', [$resourceType]), Query::orderDesc('$createdAt'), + Query::orderDesc(), Query::offset($executionsRetentionCount), ]); @@ -1741,4 +1748,25 @@ class Deletes extends Action // Swallow errors to avoid breaking the cleanup process }); } + + private function deleteExpiredPresences(Document $project, callable $getProjectDB, UsagePublisher $publisherForUsage): void + { + $dbForProject = $getProjectDB($project); + + $now = DateTime::format(new \DateTime()); + + $deleted = $dbForProject->deleteDocuments('presenceLogs', [ + Query::lessThan('expiresAt', $now), + ], onError: function (Throwable $th) { + // Swallow errors to avoid breaking the cleanup process + }); + + if ($deleted > 0) { + $usage = (new UsageContext())->addMetric(METRIC_USERS_PRESENCE, -$deleted); + $publisherForUsage->enqueue(new Usage( + project: $project, + metrics: $usage->getMetrics(), + )); + } + } } diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index 2706d33e2a..91cf3a1246 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -80,9 +80,50 @@ class StatsResources extends Action // Reset documents for each job $this->documents = []; + if ($statsResources->gauges !== []) { + try { + $this->writeGauges($getLogsDB, $project, $statsResources->gauges); + } catch (Throwable $th) { + call_user_func_array($this->logError, [$th, "StatsResources", "write_gauges_{$project->getId()}"]); + } + + return; + } + $this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $getDatabasesDB, $project); } + /** + * Write a batch of pre-computed gauge metrics to the logs database for one project. + * + * Builds (1h, 1d, inf) period documents for each metric using the existing + * createStatsDocuments helper, then commits via writeDocuments — same code path the + * standard counting flow uses, just with externally-supplied values. + * + * @param callable $getLogsDB + * @param Document $project + * @param array $gauges + */ + protected function writeGauges(callable $getLogsDB, Document $project, array $gauges): void + { + $region = $project->getAttribute('region', ''); + + foreach ($gauges as $gauge) { + if ($gauge['metric'] === '') { + continue; + } + $this->createStatsDocuments($region, $gauge['metric'], $gauge['value']); + } + + if ($this->documents === []) { + return; + } + + /** @var \Utopia\Database\Database $dbForLogs */ + $dbForLogs = call_user_func($getLogsDB, $project); + $this->writeDocuments($dbForLogs, $project); + } + protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, callable $getDatabasesDB, Document $project): void { /** @var \Utopia\Database\Database $dbForLogs */ diff --git a/src/Appwrite/Presences/State.php b/src/Appwrite/Presences/State.php new file mode 100644 index 0000000000..19e7dc98b7 --- /dev/null +++ b/src/Appwrite/Presences/State.php @@ -0,0 +1,274 @@ +toString(); + } + } else { + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + if (\is_null($permissions)) { + $permissions = []; + if (!empty($user->getId()) && !$isPrivilegedUser) { + foreach ($allowedPermissions as $permission) { + $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); + } + } + } + + if (!$isAPIKey && !$isPrivilegedUser) { + $this->checkPermissions($permissions, $authorization); + } + } + + sort($permissions, SORT_STRING); + $document->setAttribute('$permissions', $permissions); + $document->setAttribute('permissionsHash', \md5(\json_encode($permissions))); + + return $document; + } + + public function upsertForUser( + Database $dbForProject, + Document $presenceDocument, + string $presenceId, + mixed $userInternalId, + ?callable $onPresenceCreated = null + ): Document { + if ($presenceId === 'unique()') { + $presenceId = ID::unique(); + } + $presenceDocument->setAttribute('$id', $presenceId); + + $presenceCreated = false; + + try { + if ($dbForProject->getAdapter()->getSupportForUpsertOnUniqueIndex()) { + $existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userInternalId', [$userInternalId])]); + if ($existingPresence->isEmpty()) { + $presenceCreated = true; + } else { + $presenceDocument->setAttribute('$id', $existingPresence->getId()); + } + $presence = $dbForProject->upsertDocument(self::COLLECTION_ID, $presenceDocument); + } else { + $presence = $dbForProject->withTransaction(function () use ($dbForProject, $presenceDocument, $userInternalId, &$presenceCreated) { + $existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userInternalId', [$userInternalId])]); + + if ($existingPresence->isEmpty()) { + $presenceCreated = true; + return $dbForProject->createDocument(self::COLLECTION_ID, $presenceDocument); + } + + $currentPresence = $dbForProject->getDocument(self::COLLECTION_ID, $existingPresence->getId(), forUpdate: true); + + if ($currentPresence->isEmpty()) { + throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$existingPresence->getId()]); + } + + $presenceDocument->setAttribute('$id', $currentPresence->getId()); + + return $dbForProject->updateDocument(self::COLLECTION_ID, $currentPresence->getId(), $presenceDocument); + }); + } + + if ($presenceCreated && $onPresenceCreated !== null) { + call_user_func($onPresenceCreated); + } + + return $presence; + } catch (DuplicateException $e) { + throw new Exception(Exception::PRESENCE_ALREADY_EXISTS, params: [$presenceId], previous: $e); + } catch (NotFoundException $e) { + throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId], previous: $e); + } catch (StructureException $e) { + throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e); + } catch (ConflictException $e) { + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e); + } + } + + private function checkPermissions(array $permissions, Authorization $authorization): void + { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + + if (!$authorization->hasRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $authorization->getRoles()) . ')'); + } + } + } + } + + private function getListCacheFieldKey(array $roles, array $queries, string $type): string + { + $serialized = \array_map( + static fn ($query) => $query instanceof Query ? $query->toArray() : $query, + $queries, + ); + + return \sprintf( + '%s:%s:%s', + \md5(\json_encode($roles)), + \md5(\json_encode($serialized)), + $type, + ); + } + + public function getListCacheField( + Database $dbForProject, + array $roles, + array $queries, + string $type, + int $ttl + ): mixed { + $cacheField = $this->getListCacheFieldKey($roles, $queries, $type); + [$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID); + + try { + return $dbForProject->getCache()->load($collectionKey, $ttl, $cacheField); + } catch (\Throwable) { + return null; + } + } + + public function setListCacheField( + Database $dbForProject, + array $roles, + array $queries, + string $type, + mixed $value + ): void { + $cacheField = $this->getListCacheFieldKey($roles, $queries, $type); + [$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID); + + try { + $dbForProject->getCache()->save($collectionKey, $value, $cacheField); + } catch (\Throwable) { + } + } + + public function purgeListCache(Database $dbForProject): bool + { + [$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID); + + return $dbForProject->getCache()->purge($collectionKey); + } + + public function triggerUsage( + UsagePublisher $publisher, + Document $project, + int $value, + ): void { + if ($project->isEmpty()) { + return; + } + + try { + $usage = new UsageContext(); + $usage->addMetric(METRIC_USERS_PRESENCE, $value); + + $publisher->enqueue(new UsageMessage( + project: $project, + metrics: $usage->getMetrics(), + )); + } catch (Throwable $th) { + if (\function_exists('logError')) { + \logError($th, 'realtimeStats', tags: ['projectId' => $project->getId()]); + } + } + } + + public function triggerEvent( + QueueEvent $queueForEvents, + QueueRealtime $queueForRealtime, + Document $project, + User $user, + string $eventName, + Document $presence, + ): void { + if ($project->isEmpty() || $presence->isEmpty()) { + return; + } + + try { + $queueForEvents + ->reset() + ->setProject($project) + ->setUser($user) + ->setEvent($eventName) + ->setParam('presenceId', $presence->getId()) + ->setPayload($presence->getArrayCopy()); + + $queueForRealtime + ->reset() + ->setProject($project) + ->setUser($user) + ->from($queueForEvents) + ->trigger(); + } catch (Throwable $th) { + if (\function_exists('logError')) { + \logError($th, 'realtimePresenceEvent', tags: [ + 'projectId' => $project->getId(), + 'event' => $eventName, + ]); + } + } + } +} diff --git a/src/Appwrite/Realtime/Message/Dispatcher.php b/src/Appwrite/Realtime/Message/Dispatcher.php new file mode 100644 index 0000000000..827588f624 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Dispatcher.php @@ -0,0 +1,142 @@ + + */ + private array $handlers = []; + + public function addHandler(Action $handler): self + { + $labels = $handler->getLabels(); + $type = $labels[self::LABEL_MESSAGE_TYPE] + ?? throw new \LogicException('Realtime message handler is missing the messageType label.'); + + $this->handlers[$type] = $handler; + return $this; + } + + /** + * @return array + */ + public function getHandlers(): array + { + return $this->handlers; + } + + /** + * Routes a parsed websocket message to the handler that registered for its `type`, + * runs param validation + dependency injection, and returns whatever the handler returns. + * Errors propagate so the caller can render them as websocket error frames. + * + * @param Container $container per-message container resolving 'connection', 'project', + * 'projectId' and any handler-declared injections. + * @param array $message decoded inbound websocket frame: `['type' => ..., 'data' => ...]`. + * @return array|null the handler's response payload (already shaped for the + * wire), or null when the handler chooses not to reply. + */ + public function dispatch(Container $container, array $message): ?array + { + $type = $message['type'] ?? ''; + if (!\is_string($type) || !isset($this->handlers[$type])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); + } + + $handler = $this->handlers[$type]; + $labels = $handler->getLabels(); + + $requiresProject = $labels[self::LABEL_REQUIRES_PROJECT] ?? true; + if ($requiresProject && empty($container->get('projectId'))) { + throw new Exception( + Exception::REALTIME_POLICY_VIOLATION, + 'Missing project context. Reconnect to the project first.' + ); + } + + $shape = $labels[self::LABEL_PAYLOAD_SHAPE] ?? self::PAYLOAD_SHAPE_OBJECT; + $dataPresent = \array_key_exists('data', $message); + $data = $dataPresent ? $message['data'] : null; + + $args = $this->resolveArgs($handler, $data, $shape, $container); + + return ($handler->getCallback())(...$args); + } + + /** + * Resolves the ordered argument list for the handler callback by walking the action's + * declared option sequence. Params come from the inbound `data` (for object shape) or + * the entire data value (for list shape). Injections come from the per-message container. + * + * @return array + */ + private function resolveArgs( + Action $handler, + mixed $data, + string $shape, + Container $container, + ): array { + $values = []; + $dataPresent = $data !== null; + foreach ($handler->getParams() as $key => $param) { + if ($shape === self::PAYLOAD_SHAPE_LIST) { + // The whole `data` field is the value of this single param. `present` reflects + // whether the inbound message actually contained the `data` key. + $present = $dataPresent; + $value = $dataPresent ? $data : $param['default']; + } else { + $present = \is_array($data) && \array_key_exists($key, $data); + $value = $present ? $data[$key] : $param['default']; + } + + if (!$present && !$param['optional']) { + throw new Exception( + Exception::REALTIME_MESSAGE_FORMAT_INVALID, + \sprintf(self::REQUIRED_PARAM_ERROR_FORMAT, \ucfirst($key)), + ); + } + + if ($present && !($param['skipValidation'] ?? false)) { + $validator = $param['validator']; + if (\is_callable($validator) && !($validator instanceof \Utopia\Validator)) { + $validator = $validator(); + } + if (!$validator->isValid($value)) { + throw new Exception( + Exception::REALTIME_MESSAGE_FORMAT_INVALID, + \sprintf('%s: %s', $key, $validator->getDescription()) + ); + } + } + + $values[$key] = $value; + } + + $ordered = []; + foreach ($handler->getOptions() as $optionKey => $option) { + if (($option['type'] ?? '') === 'param') { + $name = \substr($optionKey, \strlen('param:')); + $ordered[] = $values[$name] ?? null; + } else { + $ordered[] = $container->get($option['name']); + } + } + + return $ordered; + } +} diff --git a/src/Appwrite/Realtime/Message/Handlers/Authentication.php b/src/Appwrite/Realtime/Message/Handlers/Authentication.php new file mode 100644 index 0000000000..9065e586d3 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Handlers/Authentication.php @@ -0,0 +1,125 @@ +desc('Authenticate the connection with a session token') + ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'authentication') + ->param('session', '', new Text(2048), 'Encoded session token') + ->inject('connectionId') + ->inject('realtime') + ->inject('database') + ->inject('register') + ->inject('response') + ->callback($this->action(...)); + } + + /** + * @return array + */ + public function action( + string $session, + int $connectionId, + Realtime $realtime, + Database $database, + Registry $register, + Response $response, + ): array { + $store = new Store(); + $store->decode($session); + + $userId = $store->getProperty('id', ''); + if ($userId !== '') { + $database->purgeCachedDocument('users', $userId); + } + + /** @var User $user */ + $user = $database->getDocument('users', $userId); + + // TODO: move proof construction to the DI container so there's one source of truth. + $proofForToken = new Token(); + $proofForToken->setHash(new Sha()); + + if ( + empty($user->getId()) + || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) + ) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.'); + } + + $roles = $user->getRoles($database->getAuthorization()); + + $authorization = $realtime->connections[$connectionId]['authorization'] ?? null; + $projectId = $realtime->connections[$connectionId]['projectId'] ?? null; + // Capture the pre-auth userId before unsubscribe() clears the connection entry, + // so we can rebind any account channels that were stored under it. + $previousUserId = $realtime->connections[$connectionId]['userId'] ?? ''; + + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId)); + $meta = $realtime->getSubscriptionMetadata($connectionId); + + $realtime->unsubscribe($connectionId); + + if (!empty($projectId)) { + foreach ($meta as $subscriptionId => $subscription) { + $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $user->getId(), + ); + + $realtime->subscribe( + $projectId, + $connectionId, + $subscriptionId, + $roles, + $channels, + $queries, + $user->getId(), + ); + } + } + + if ($authorization !== null) { + $realtime->connections[$connectionId]['authorization'] = $authorization; + } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter') + ->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + + Span::add('realtime.subscription_delta', $subscriptionDelta); + + return [ + 'type' => 'response', + 'data' => [ + 'to' => 'authentication', + 'success' => true, + 'user' => $response->output($user, Response::MODEL_ACCOUNT), + ], + ]; + } +} diff --git a/src/Appwrite/Realtime/Message/Handlers/Ping.php b/src/Appwrite/Realtime/Message/Handlers/Ping.php new file mode 100644 index 0000000000..4bfcfa7060 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Handlers/Ping.php @@ -0,0 +1,28 @@ +desc('Reply to client heartbeat') + ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'ping') + ->label(Dispatcher::LABEL_REQUIRES_PROJECT, false) + ->callback($this->action(...)); + } + + /** + * @return array + */ + public function action(): array + { + return [ + 'type' => 'pong', + ]; + } +} diff --git a/src/Appwrite/Realtime/Message/Handlers/Presence.php b/src/Appwrite/Realtime/Message/Handlers/Presence.php new file mode 100644 index 0000000000..1787bc0682 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Handlers/Presence.php @@ -0,0 +1,128 @@ +desc('Upsert a presence document for the authenticated user') + ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'presence') + ->param('status', '', new Text(2048), 'Presence status') + ->param('presenceId', 'unique()', new Text(36), 'Presence document ID', true) + ->param('metadata', null, new JSON(), 'Optional metadata payload', true, [], true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('connectionId') + ->inject('realtime') + ->inject('database') + ->inject('authorization') + ->inject('presenceState') + ->inject('project') + ->inject('publisherForUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->callback($this->action(...)); + } + + /** + * @param array|null $permissions + * @return array + */ + public function action( + string $status, + string $presenceId, + mixed $metadata, + ?array $permissions, + int $connectionId, + Realtime $realtime, + Database $database, + Authorization $authorization, + PresenceState $presenceState, + ?Document $project, + UsagePublisher $publisherForUsage, + QueueEvent $queueForEvents, + QueueRealtime $queueForRealtime, + ): array { + if ($project === null || $project->isEmpty()) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Presence requires a project context.'); + } + + $userId = $realtime->connections[$connectionId]['userId'] ?? ''; + if (empty($userId)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'User must be authorized'); + } + + $user = new User($database->getDocument('users', $userId)->getArrayCopy()); + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]); + } + + $presenceData = [ + 'userInternalId' => $user->getSequence(), + 'userId' => $user->getId(), + 'source' => 'realtime', + 'status' => $status, + 'expiresAt' => DateTime::format((new \DateTime())->modify('+30 days')), + 'hostname' => \gethostname() ?: null, + ]; + if ($metadata !== null) { + $presenceData['metadata'] = $metadata; + } + + $presenceDocument = new Document($presenceData); + $presenceState->setPermissions($presenceDocument, $permissions, $user, $authorization); + + $presence = $presenceState->upsertForUser( + $database, + $presenceDocument, + $presenceId, + (string) $user->getSequence(), + function () use ($presenceState, $publisherForUsage, $project): void { + $presenceState->triggerUsage($publisherForUsage, $project, 1); + }, + ); + + $presence->removeAttribute('$collection'); + $presence->removeAttribute('$tenant'); + $presence->removeAttribute('hostname'); + $presence->removeAttribute('permissionsHash'); + $presence->removeAttribute('userInternalId'); + + $realtime->connections[$connectionId]['presences'][$presence->getId()] = $presence; + + $presenceState->triggerEvent( + $queueForEvents, + $queueForRealtime, + $project, + $user, + 'presences.[presenceId].upsert', + $presence, + ); + + return [ + 'type' => 'response', + 'data' => [ + 'to' => 'presence', + 'presence' => $presence->getArrayCopy(), + ], + ]; + } +} diff --git a/src/Appwrite/Realtime/Message/Handlers/Subscribe.php b/src/Appwrite/Realtime/Message/Handlers/Subscribe.php new file mode 100644 index 0000000000..140cd800c3 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Handlers/Subscribe.php @@ -0,0 +1,109 @@ +desc('Bulk subscribe to realtime channels') + ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'subscribe') + ->label(Dispatcher::LABEL_PAYLOAD_SHAPE, Dispatcher::PAYLOAD_SHAPE_LIST) + ->param('items', null, fn () => new SubscribePayloadValidator(), 'Subscriptions to add') + ->inject('connectionId') + ->inject('realtime') + ->inject('register') + ->inject('projectId') + ->callback($this->action(...)); + } + + /** + * @param array, queries?: array, subscriptionId?: string}> $items + * @return array + */ + public function action( + array $items, + int $connectionId, + Realtime $realtime, + Registry $register, + ?string $projectId, + ): array { + $roles = $realtime->connections[$connectionId]['roles'] ?? [Role::guests()->toString()]; + $userId = $realtime->connections[$connectionId]['userId'] ?? ''; + + $parsedPayloads = []; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId)); + + foreach ($items as $payload) { + $subscriptionId = \array_key_exists('subscriptionId', $payload) + ? $payload['subscriptionId'] + : ID::unique(); + + $queries = $payload['queries'] ?? []; + + try { + $convertedQueries = Realtime::convertQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); + } + + $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); + + $parsedPayloads[] = [ + 'subscriptionId' => $subscriptionId, + 'channels' => $payload['channels'], + 'convertedChannels' => $convertedChannels, + 'queries' => $convertedQueries, + ]; + } + + foreach ($parsedPayloads as $parsedPayload) { + $realtime->subscribe( + $projectId, + $connectionId, + $parsedPayload['subscriptionId'], + $roles, + $parsedPayload['convertedChannels'], + $parsedPayload['queries'], + ); + } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($parsedPayloads); + + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter') + ->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + + Span::add('realtime.subscription_delta', $subscriptionDelta); + Span::add('realtime.subscriptions_requested', $subscriptionsRequested); + Span::add('realtime.subscribe.subscriptions_count', $subscriptionsRequested); + + return [ + 'type' => 'response', + 'data' => [ + 'to' => 'subscribe', + 'success' => true, + 'subscriptions' => \array_map(static fn (array $parsed): array => [ + 'subscriptionId' => $parsed['subscriptionId'], + 'channels' => $parsed['convertedChannels'], + 'queries' => \array_map(static fn ($q) => $q->toString(), $parsed['queries']), + ], $parsedPayloads), + ], + ]; + } +} diff --git a/src/Appwrite/Realtime/Message/Handlers/Unsubscribe.php b/src/Appwrite/Realtime/Message/Handlers/Unsubscribe.php new file mode 100644 index 0000000000..e1bc163f68 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Handlers/Unsubscribe.php @@ -0,0 +1,74 @@ +desc('Bulk remove subscriptions by id') + ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'unsubscribe') + ->label(Dispatcher::LABEL_PAYLOAD_SHAPE, Dispatcher::PAYLOAD_SHAPE_LIST) + ->param('items', null, fn () => new UnsubscribePayloadValidator(), 'Subscriptions to remove') + ->inject('connectionId') + ->inject('realtime') + ->inject('register') + ->callback($this->action(...)); + } + + /** + * @param array $items + * @return array + */ + public function action( + array $items, + int $connectionId, + Realtime $realtime, + Registry $register, + ): array { + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId)); + + $unsubscribeResults = []; + foreach ($items as $payload) { + $subscriptionId = $payload['subscriptionId']; + $unsubscribeResults[] = [ + 'subscriptionId' => $subscriptionId, + 'removed' => $realtime->unsubscribeSubscription($connectionId, $subscriptionId), + ]; + } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($items); + $subscriptionsRemoved = \count(\array_filter( + $unsubscribeResults, + static fn (array $item): bool => $item['removed'] + )); + + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter') + ->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + + Span::add('realtime.subscription_delta', $subscriptionDelta); + Span::add('realtime.subscriptions_requested', $subscriptionsRequested); + Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); + + return [ + 'type' => 'response', + 'data' => [ + 'to' => 'unsubscribe', + 'success' => true, + 'subscriptions' => $unsubscribeResults, + ], + ]; + } +} diff --git a/src/Appwrite/Realtime/Message/Validators/SubscribePayload.php b/src/Appwrite/Realtime/Message/Validators/SubscribePayload.php new file mode 100644 index 0000000000..3e7e5cec10 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Validators/SubscribePayload.php @@ -0,0 +1,69 @@ +description; + } + + public function isArray(): bool + { + return true; + } + + public function getType(): string + { + return self::TYPE_ARRAY; + } + + public function isValid(mixed $value): bool + { + if (!\is_array($value) || !\array_is_list($value)) { + $this->description = 'Payload is not valid.'; + return false; + } + + $customId = new CustomId(); + + foreach ($value as $payload) { + if (!\is_array($payload)) { + $this->description = 'Each subscribe payload must be an object.'; + return false; + } + if (\array_key_exists('subscriptionId', $payload) && !$customId->isValid($payload['subscriptionId'])) { + $this->description = 'subscriptionId is not a valid id.'; + return false; + } + if (!\array_key_exists('channels', $payload)) { + $this->description = 'channels is not present in payload.'; + return false; + } + if (!\is_array($payload['channels']) || !\array_is_list($payload['channels'])) { + $this->description = 'channels is not a valid array.'; + return false; + } + foreach ($payload['channels'] as $channel) { + if (!\is_string($channel)) { + $this->description = 'channels must contain only strings.'; + return false; + } + } + if (\array_key_exists('queries', $payload) + && (!\is_array($payload['queries']) || !\array_is_list($payload['queries'])) + ) { + $this->description = 'queries is not a valid array.'; + return false; + } + } + + return true; + } +} diff --git a/src/Appwrite/Realtime/Message/Validators/UnsubscribePayload.php b/src/Appwrite/Realtime/Message/Validators/UnsubscribePayload.php new file mode 100644 index 0000000000..fca065dec8 --- /dev/null +++ b/src/Appwrite/Realtime/Message/Validators/UnsubscribePayload.php @@ -0,0 +1,47 @@ +description; + } + + public function isArray(): bool + { + return true; + } + + public function getType(): string + { + return self::TYPE_ARRAY; + } + + public function isValid(mixed $value): bool + { + if (!\is_array($value) || !\array_is_list($value)) { + $this->description = 'Payload is not valid.'; + return false; + } + + foreach ($value as $payload) { + if ( + !\is_array($payload) + || !\array_key_exists('subscriptionId', $payload) + || !\is_string($payload['subscriptionId']) + || $payload['subscriptionId'] === '' + ) { + $this->description = 'Each unsubscribe payload must include a non-empty subscriptionId.'; + return false; + } + } + + return true; + } +} diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 6c5d50e016..0cbd83cf3f 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -29,6 +29,7 @@ abstract class Format 'name' => '', 'description' => '', 'endpoint' => 'https://localhost', + 'endpoint.docs' => 'https://.cloud.appwrite.io/v1', 'version' => '1.0.0', 'terms' => '', 'support.email' => '', @@ -923,6 +924,16 @@ abstract class Format break; } break; + case 'presences': + switch ($method) { + case 'getUsage': + switch ($param) { + case 'range': + return 'UsageRange'; + } + break; + } + break; } return null; } @@ -1022,6 +1033,19 @@ abstract class Format return $values; } + protected function shouldEmitDefaultForSchema(mixed $default, array $schema): bool + { + if (isset($schema['enum'])) { + return \in_array($default, $schema['enum'], true); + } + + if (isset($schema['items']['enum'])) { + return \is_array($default) && empty(\array_diff($default, $schema['items']['enum'])); + } + + return true; + } + protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array { $config = [ diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 68ab7a0986..117fb5e321 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -55,11 +55,22 @@ class OpenAPI3 extends Format 'servers' => [ [ 'url' => $this->getParam('endpoint', ''), + 'description' => 'Appwrite Cloud endpoint.', ], [ - 'url' => $this->getParam('endpoint.docs', ''), + 'url' => \str_replace('', '{region}', $this->getParam('endpoint.docs', '')), + 'description' => 'Appwrite Cloud regional endpoint. Replace `{region}` with your project region.', + 'variables' => [ + 'region' => [ + 'default' => 'fra', + 'description' => 'Appwrite Cloud region.', + ], + ], ], ], + 'x-appwrite' => [ + 'endpointDocs' => $this->getParam('endpoint.docs', ''), + ], 'paths' => [], 'tags' => $this->services, 'components' => [ @@ -768,7 +779,7 @@ class OpenAPI3 extends Format break; } - if ($parameter['emitDefault']) { // Param has default value + if ($parameter['emitDefault'] && $this->shouldEmitDefaultForSchema($param['default'], $node['schema'])) { // Param has default value $node['schema']['default'] = $param['default']; } @@ -869,6 +880,13 @@ class OpenAPI3 extends Format if ($model->isAny()) { $output['components']['schemas'][$model->getType()]['additionalProperties'] = true; + + $additionalKey = \method_exists($model, 'getAdditionalPropertiesKey') + ? $model->getAdditionalPropertiesKey() + : null; + if ($additionalKey !== null) { + $output['components']['schemas'][$model->getType()]['x-additional-properties-key'] = $additionalKey; + } } if (!empty($required)) { diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index fb1ef66eca..f0cd52bf99 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -55,6 +55,9 @@ class Swagger2 extends Format ], 'host' => \parse_url($this->getParam('endpoint', ''), PHP_URL_HOST), 'x-host-docs' => \parse_url($this->getParam('endpoint.docs', ''), PHP_URL_HOST), + 'x-appwrite' => [ + 'endpointDocs' => $this->getParam('endpoint.docs', ''), + ], 'basePath' => \parse_url($this->getParam('endpoint', ''), PHP_URL_PATH), 'schemes' => [\parse_url($this->getParam('endpoint', ''), PHP_URL_SCHEME)], 'consumes' => ['application/json', 'multipart/form-data'], @@ -731,7 +734,7 @@ class Swagger2 extends Format break; } - if ($parameter['emitDefault']) { // Param has default value + if ($parameter['emitDefault'] && $this->shouldEmitDefaultForSchema($param['default'], $node)) { // Param has default value $node['default'] = $param['default']; } @@ -767,10 +770,13 @@ class Swagger2 extends Format $body['schema']['properties'][$name] = [ 'type' => $node['type'], 'description' => $node['description'], - 'default' => $node['default'] ?? null, 'x-example' => $node['x-example'] ?? null, ]; + if (\array_key_exists('default', $node)) { + $body['schema']['properties'][$name]['default'] = $node['default']; + } + if (isset($node['format'])) { $body['schema']['properties'][$name]['format'] = $node['format']; } @@ -838,6 +844,13 @@ class Swagger2 extends Format if ($model->isAny()) { $output['definitions'][$model->getType()]['additionalProperties'] = true; + + $additionalKey = \method_exists($model, 'getAdditionalPropertiesKey') + ? $model->getAdditionalPropertiesKey() + : null; + if ($additionalKey !== null) { + $output['definitions'][$model->getType()]['x-additional-properties-key'] = $additionalKey; + } } if (!empty($required)) { diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Presences.php b/src/Appwrite/Utopia/Database/Validator/Queries/Presences.php new file mode 100644 index 0000000000..b1ee7b6fbd --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Presences.php @@ -0,0 +1,23 @@ +'s `data` slot). Default null means + * SDK templates fall back to their hardcoded "data" key. Set this on + * subclasses (via setAdditionalPropertiesKey) to use a custom key like + * "metadata" while still benefiting from the generic `Model` mapping. + */ + protected ?string $additionalPropertiesKey = null; + + public function setAdditionalPropertiesKey(string $key): self + { + $this->additionalPropertiesKey = $key; + return $this; + } + + public function getAdditionalPropertiesKey(): ?string + { + return $this->additionalPropertiesKey; + } + /** * Get Name * diff --git a/src/Appwrite/Utopia/Response/Model/File.php b/src/Appwrite/Utopia/Response/Model/File.php index 9b3e6ff618..61dd496f52 100644 --- a/src/Appwrite/Utopia/Response/Model/File.php +++ b/src/Appwrite/Utopia/Response/Model/File.php @@ -67,6 +67,12 @@ class File extends Model 'default' => 0, 'example' => 17890, ]) + ->addRule('sizeActual', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'File actual stored size in bytes after compression and/or encryption.', + 'default' => 0, + 'example' => 12345, + ]) ->addRule('chunksTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total number of chunks available', diff --git a/src/Appwrite/Utopia/Response/Model/Presence.php b/src/Appwrite/Utopia/Response/Model/Presence.php new file mode 100644 index 0000000000..ecc3755bae --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Presence.php @@ -0,0 +1,103 @@ +setAdditionalPropertiesKey('metadata'); + + $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Presence ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Presence creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Presence update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$permissions', [ + 'type' => self::TYPE_STRING, + 'description' => 'Presence permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', + 'default' => '', + 'example' => ['read("any")'], + 'array' => true, + ]) + ->addRule('userId', [ + 'type' => self::TYPE_STRING, + 'description' => 'User ID.', + 'default' => '', + 'example' => '674af8f3e12a5f9ac0be', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Presence status.', + 'required' => false, + 'default' => null, + 'example' => 'online', + ]) + ->addRule('source', [ + 'type' => self::TYPE_STRING, + 'description' => 'Presence source.', + 'default' => '', + 'example' => 'HTTP', + ]) + ->addRule('expiresAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Presence expiry date in ISO 8601 format.', + 'required' => false, + 'default' => null, + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]); + // User-defined extras flow through Any's generic mapping, surfaced under + // the "metadata" key declared via setAdditionalPropertiesKey() above. + } + + public function filter(DatabaseDocument $document): DatabaseDocument + { + $document->removeAttribute('$collection'); + $document->removeAttribute('$tenant'); + $document->removeAttribute('hostname'); + $document->removeAttribute('permissionsHash'); + $document->removeAttribute('userInternalId'); + + foreach ($document->getAttributes() as $attribute) { + if (\is_array($attribute)) { + foreach ($attribute as $subAttribute) { + if ($subAttribute instanceof DatabaseDocument) { + $this->filter($subAttribute); + } + } + } elseif ($attribute instanceof DatabaseDocument) { + $this->filter($attribute); + } + } + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsagePresence.php b/src/Appwrite/Utopia/Response/Model/UsagePresence.php new file mode 100644 index 0000000000..f679d4c00c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsagePresence.php @@ -0,0 +1,43 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('usersOnlineTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Current total number of online users.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('presences', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of online users per period.', + 'default' => [], + 'example' => [], + 'array' => true, + ]); + } + + public function getName(): string + { + return 'UsagePresence'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_PRESENCE; + } +} diff --git a/src/Utopia/Bus/Bus.php b/src/Utopia/Bus/Bus.php index bef39f0481..2debb22f98 100644 --- a/src/Utopia/Bus/Bus.php +++ b/src/Utopia/Bus/Bus.php @@ -37,14 +37,19 @@ class Bus foreach ($listeners as $listener) { $deps = array_map($resolver, $listener->getInjections()); - Span::init('listener.' . $listener::getName()); - Span::add('bus.event', $event::class); + + Span::current()?->add('listener.' . $listener::getName() . '.event', $event::class); + try { ($listener->getCallback())($event, ...$deps); + Span::current()?->add('listener.' . $listener::getName() . '.success', true); } catch (\Throwable $e) { - Span::error($e); - } finally { - Span::current()?->finish(); + Span::current()?->add('listener.' . $listener::getName() . '.success', false); + Span::current()?->add('listener.' . $listener::getName() . '.error.code', $e->getCode()); + Span::current()?->add('listener.' . $listener::getName() . '.error.message', $e->getMessage()); + Span::current()?->add('listener.' . $listener::getName() . '.error.line', $e->getLine()); + Span::current()?->add('listener.' . $listener::getName() . '.error.file', $e->getFile()); + Span::current()?->add('listener.' . $listener::getName() . '.error.trace', $e->getTraceAsString()); } } } diff --git a/tests/e2e/General/HTTPTest.php b/tests/e2e/General/HTTPTest.php index 450e4f2378..0358281eb7 100644 --- a/tests/e2e/General/HTTPTest.php +++ b/tests/e2e/General/HTTPTest.php @@ -101,7 +101,7 @@ class HTTPTest extends Scope $body = $response['body']; $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsString($body['server']); - $this->assertIsString($body['client-web']); + $this->assertIsString($body['server-web']); $this->assertIsString($body['client-flutter']); $this->assertIsString($body['console-web']); $this->assertIsString($body['server-nodejs']); diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index 4f557e8959..7d0e858bbb 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -18,6 +18,7 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\System\System; +use WebSocket\Client as WebSocketClient; class UsageTest extends Scope { @@ -227,6 +228,110 @@ class UsageTest extends Scope } #[Depends('testUsersStats')] + public function testPreparePresenceStats(array $data): array + { + $presenceKey = $this->getNewKey([ + 'presences.read', + 'presences.write', + ]); + $projectId = $this->getProject()['$id']; + + $apiUser = $this->getUser(true); + $apiPresence = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $presenceKey, + ], + [ + 'userId' => $apiUser['$id'], + 'status' => 'online', + 'metadata' => [ + 'source' => 'api', + 'testRunId' => ID::unique(), + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + $this->assertEquals(200, $apiPresence['headers']['status-code']); + + return $data; + } + + #[Depends('testPreparePresenceStats')] + #[Retry(count: 1)] + public function testPresenceStats(array $data): array + { + $projectId = $this->getProject()['$id']; + $realtimeUser = $this->getUser(true); + $realtime = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . \http_build_query([ + 'project' => $projectId, + ]), + [ + 'headers' => [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $realtimeUser['session'], + ], + 'timeout' => 2, + ] + ); + + try { + $connected = \json_decode($realtime->receive(), true); + $this->assertSame('connected', $connected['type'] ?? null); + + $presenceId = ID::unique(); + $realtime->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'presenceId' => $presenceId, + 'status' => 'online', + 'metadata' => [ + 'source' => 'realtime', + 'testRunId' => ID::unique(), + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + ])); + + $response = \json_decode($realtime->receive(), true); + $this->assertSame('response', $response['type'] ?? null); + $this->assertSame('presence', $response['data']['to'] ?? null); + $this->assertSame($presenceId, $response['data']['presence']['$id'] ?? null); + + $this->assertEventually(function () { + $response = $this->client->call( + Client::METHOD_GET, + '/presences/usage?range=90d', + $this->getConsoleHeaders() + ); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('90d', $response['body']['range']); + $this->assertEquals(90, count($response['body']['presences'])); + $this->assertEquals(2, $response['body']['usersOnlineTotal']); + $this->assertEquals(2, $response['body']['presences'][array_key_last($response['body']['presences'])]['value']); + $this->validateDates($response['body']['presences']); + }); + } finally { + $realtime->close(); + } + + return $data; + } + + #[Depends('testPresenceStats')] public function testPrepareStorageStats(array $data): array { $requestsTotal = $data['requestsTotal']; diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 160ee39e21..5b0d947198 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -1026,123 +1026,101 @@ class AccountCustomClientTest extends Scope // Use fresh account for predictable log count $data = $this->createFreshAccountWithSession(); $session = $data['session']; + $headers = array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ]); /** * Test for SUCCESS */ - $response = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); + $this->assertEventually(function () use ($headers) { + $response = $this->client->call(Client::METHOD_GET, '/account/logs', $headers); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsArray($response['body']['logs']); - $this->assertNotEmpty($response['body']['logs']); - // Fresh account: session.create is always logged. user.create audit may or may not - // be present depending on async audit processing timing. - $logCount = count($response['body']['logs']); - $this->assertContains($logCount, [1, 2]); - $this->assertIsNumeric($response['body']['total']); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['logs']); + $this->assertNotEmpty($response['body']['logs']); + $logCount = count($response['body']['logs']); + $this->assertContains($logCount, [1, 2]); + $this->assertIsNumeric($response['body']['total']); - // Check session.create log (logs[0] - most recent) - $this->assertEquals('Windows', $response['body']['logs'][0]['osName']); - $this->assertEquals('WIN', $response['body']['logs'][0]['osCode']); - $this->assertEquals('10', $response['body']['logs'][0]['osVersion']); + $this->assertEquals('session.create', $response['body']['logs'][0]['event']); + $this->assertEquals('Windows', $response['body']['logs'][0]['osName']); + $this->assertEquals('WIN', $response['body']['logs'][0]['osCode']); + $this->assertEquals('10', $response['body']['logs'][0]['osVersion']); - $this->assertEquals('browser', $response['body']['logs'][0]['clientType']); - $this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']); - $this->assertEquals('CH', $response['body']['logs'][0]['clientCode']); - $this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']); - $this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']); + $this->assertEquals('browser', $response['body']['logs'][0]['clientType']); + $this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']); + $this->assertEquals('CH', $response['body']['logs'][0]['clientCode']); + $this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']); + $this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']); - $this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']); - $this->assertEquals('', $response['body']['logs'][0]['deviceBrand']); - $this->assertEquals('', $response['body']['logs'][0]['deviceModel']); - $this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']); + $this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']); + $this->assertEquals('', $response['body']['logs'][0]['deviceBrand']); + $this->assertEquals('', $response['body']['logs'][0]['deviceModel']); + $this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']); - $this->assertEquals('--', $response['body']['logs'][0]['countryCode']); - $this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']); + $this->assertEquals('--', $response['body']['logs'][0]['countryCode']); + $this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']); - if ($logCount === 2) { - // Check user.create log (logs[1] - oldest) - $this->assertEquals('user.create', $response['body']['logs'][1]['event']); - $this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']); - $this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time'])); - } + if ($logCount === 2) { + $this->assertEquals('user.create', $response['body']['logs'][1]['event']); + $this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']); + $this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time'])); + } - $responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ]), [ - 'queries' => [ - Query::limit(1)->toString() - ] - ]); + $responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ + 'queries' => [ + Query::limit(1)->toString() + ] + ]); - $this->assertEquals(200, $responseLimit['headers']['status-code']); - $this->assertIsArray($responseLimit['body']['logs']); - $this->assertNotEmpty($responseLimit['body']['logs']); - $this->assertCount(1, $responseLimit['body']['logs']); - $this->assertIsNumeric($responseLimit['body']['total']); + $this->assertEquals(200, $responseLimit['headers']['status-code']); + $this->assertIsArray($responseLimit['body']['logs']); + $this->assertNotEmpty($responseLimit['body']['logs']); + $this->assertCount(1, $responseLimit['body']['logs']); + $this->assertIsNumeric($responseLimit['body']['total']); - $this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]); + $this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]); - $responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ]), [ - 'queries' => [ - Query::offset(1)->toString() - ] - ]); + $responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ + 'queries' => [ + Query::offset(1)->toString() + ] + ]); - $this->assertEquals($responseOffset['headers']['status-code'], 200); - $this->assertIsArray($responseOffset['body']['logs']); - // With offset(1), remaining logs = logCount - 1 - $this->assertCount($logCount - 1, $responseOffset['body']['logs']); - $this->assertIsNumeric($responseOffset['body']['total']); + $this->assertEquals(200, $responseOffset['headers']['status-code']); + $this->assertIsArray($responseOffset['body']['logs']); + $this->assertCount($logCount - 1, $responseOffset['body']['logs']); + $this->assertIsNumeric($responseOffset['body']['total']); - if ($logCount === 2) { - $this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]); - } + if ($logCount === 2) { + $this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]); + } - $responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ]), [ - 'queries' => [ - Query::offset(1)->toString(), - Query::limit(1)->toString() - ] - ]); + $responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ + 'queries' => [ + Query::offset(1)->toString(), + Query::limit(1)->toString() + ] + ]); - $this->assertEquals(200, $responseLimitOffset['headers']['status-code']); - $this->assertIsArray($responseLimitOffset['body']['logs']); - // With offset(1)+limit(1), remaining logs = min(1, logCount - 1) - $this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']); - $this->assertIsNumeric($responseLimitOffset['body']['total']); + $this->assertEquals(200, $responseLimitOffset['headers']['status-code']); + $this->assertIsArray($responseLimitOffset['body']['logs']); + $this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']); + $this->assertIsNumeric($responseLimitOffset['body']['total']); - if ($logCount === 2) { - $this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]); - } + if ($logCount === 2) { + $this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]); + } + }); /** * Test for total=false */ - $logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ]), [ + $logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ 'total' => false ]); diff --git a/tests/e2e/Services/GraphQL/PresenceTest.php b/tests/e2e/Services/GraphQL/PresenceTest.php new file mode 100644 index 0000000000..b0329e1c97 --- /dev/null +++ b/tests/e2e/Services/GraphQL/PresenceTest.php @@ -0,0 +1,52 @@ +getProject()['$id']; + $apiKey = $this->getNewKey(['presences.write']); + $user = $this->getUser(true); + + $payload = [ + 'query' => <<<'GQL' + mutation upsert($presenceId: String!, $userId: String!, $status: String!, $metadata: Json) { + presencesUpsert(presenceId: $presenceId, userId: $userId, status: $status, metadata: $metadata) { + _id + userId + status + source + } + } + GQL, + 'variables' => [ + 'presenceId' => ID::unique(), + 'userId' => $user['$id'], + 'status' => 'online', + 'metadata' => [ + 'testRunId' => ID::unique(), + ], + ], + ]; + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], $payload); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('online', $response['body']['data']['presencesUpsert']['status']); + $this->assertEquals('graphql', $response['body']['data']['presencesUpsert']['source']); + } +} diff --git a/tests/e2e/Services/Presences/PresenceBase.php b/tests/e2e/Services/Presences/PresenceBase.php new file mode 100644 index 0000000000..1c94ade61b --- /dev/null +++ b/tests/e2e/Services/Presences/PresenceBase.php @@ -0,0 +1,1107 @@ +getProject()['$id']; + + if (!empty(self::$presenceApiKeyCache[$projectId])) { + return self::$presenceApiKeyCache[$projectId]; + } + + self::$presenceApiKeyCache[$projectId] = $this->getNewKey([ + 'presences.read', + 'presences.write', + ]); + + return self::$presenceApiKeyCache[$projectId]; + } + + /** + * Server-side helper: ensure presences requests use a presence-scoped API key. + */ + protected function getPresenceServerHeaders(): array + { + $headers = $this->getHeaders(false); + + // Override the project API key added by `SideServer` with a presence-scoped key. + $headers['x-appwrite-key'] = $this->getPresenceApiKey(); + + return $headers; + } + + protected function setupPresence(array $overrides = []): array + { + $projectId = $this->getProject()['$id']; + $cacheKey = $projectId; + + if (empty($overrides) && !empty(self::$presenceCache[$cacheKey])) { + return self::$presenceCache[$cacheKey]; + } + + $payload = \array_merge([ + 'userId' => $this->getUser()['$id'], + 'status' => 'online', + 'metadata' => [ + 'device' => 'web', + 'setup' => true, + ], + ], $overrides); + + $response = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ], + $payload + ); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertArrayHasKey('userId', $response['body']); + $this->assertArrayHasKey('status', $response['body']); + $this->assertArrayHasKey('metadata', $response['body']); + + $this->assertEquals($payload['userId'], $response['body']['userId']); + + $canonicalPresence = $this->client->call( + Client::METHOD_GET, + '/presences', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ], + [ + 'queries' => [ + Query::equal('userId', [$payload['userId']])->toString(), + ], + ] + ); + $this->assertEquals(200, $canonicalPresence['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $canonicalPresence['body']['total'] ?? 0); + $this->assertNotEmpty($canonicalPresence['body']['presences'][0] ?? []); + + $presence = $canonicalPresence['body']['presences'][0]; + + if (empty($overrides)) { + self::$presenceCache[$cacheKey] = $presence; + } + + return $presence; + } + + protected function resolvePresenceForUser(string $userId, array $headers): array + { + $presence = $this->client->call( + Client::METHOD_GET, + '/presences', + $headers, + [ + 'queries' => [ + Query::equal('userId', [$userId])->toString(), + ], + ] + ); + + $this->assertEquals(200, $presence['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $presence['body']['total'] ?? 0); + $this->assertNotEmpty($presence['body']['presences'][0] ?? []); + + return $presence['body']['presences'][0]; + } + + public function testUpsertAndGetPresence(): void + { + if ($this->getSide() === 'client') { + $userId = $this->getUser()['$id']; + + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'online', + 'metadata' => ['device' => 'web'], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $this->assertNotEmpty($upsert['body']['$id']); + $this->assertEquals($userId, $upsert['body']['userId']); + + $get = $this->client->call( + Client::METHOD_GET, + '/presences/' . $upsert['body']['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($upsert['body']['$id'], $get['body']['$id']); + $this->assertEquals($userId, $get['body']['userId']); + $this->assertArrayHasKey('expiresAt', $get['body']); + + return; + } + + $presence = $this->setupPresence(); + + $get = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()) + ); + + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($presence['$id'], $get['body']['$id']); + $this->assertEquals($presence['userId'], $get['body']['userId']); + $this->assertArrayHasKey('expiresAt', $get['body']); + } + + public function testListPresences(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'online', + 'metadata' => ['device' => 'web'], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $this->assertNotEmpty($upsert['body']['$id']); + $this->assertArrayHasKey('userId', $upsert['body']); + + $list = $this->client->call( + Client::METHOD_GET, + '/presences', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'queries' => [ + Query::equal('userId', [$upsert['body']['userId']])->toString(), + ], + ] + ); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertArrayHasKey('total', $list['body']); + $this->assertArrayHasKey('presences', $list['body']); + $this->assertIsArray($list['body']['presences']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Client sessions must not be able to list presences belonging to a different user. + $projectId = $this->getProject()['$id']; + $originalUser = $this->getUser(); + $otherUserId = $this->getUser(true)['$id']; + + // Important: don't let `getUser(true)` overwrite the cached user/session for the rest + // of this test run. We only need the other user's ID. + self::$user[$projectId] = $originalUser; + + // Seed another presence for the other user (setup via API key, not the client session). + $this->setupPresence([ + 'userId' => $otherUserId, + 'status' => 'online', + 'metadata' => ['device' => 'other-user'], + ]); + + $otherList = $this->client->call( + Client::METHOD_GET, + '/presences', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'queries' => [ + Query::equal('userId', [$otherUserId])->toString(), + ], + ] + ); + + $this->assertEquals(200, $otherList['headers']['status-code']); + $this->assertArrayHasKey('total', $otherList['body']); + $this->assertArrayHasKey('presences', $otherList['body']); + $this->assertSame([], $otherList['body']['presences']); + $this->assertEquals(0, $otherList['body']['total']); + return; + } + + $presence = $this->setupPresence(); + + $list = $this->client->call( + Client::METHOD_GET, + '/presences', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()), + [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + ] + ); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertArrayHasKey('total', $list['body']); + $this->assertArrayHasKey('presences', $list['body']); + $this->assertIsArray($list['body']['presences']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + } + + public function testClientPresenceCustomPermissionsForOtherUser(): void + { + if ($this->getSide() !== 'client') { + $this->expectNotToPerformAssertions(); + return; + } + + $projectId = $this->getProject()['$id']; + $user1 = $this->getUser(true); + $user2 = $this->getUser(true); + $headersUser2 = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'], + ]; + + $permissionsForUser2 = [ + Permission::read(Role::user($user2['$id'])), + Permission::update(Role::user($user2['$id'])), + Permission::delete(Role::user($user2['$id'])), + Permission::write(Role::user($user2['$id'])), + ]; + + $permissionsForUser1 = [ + Permission::read(Role::user($user1['$id'])), + Permission::update(Role::user($user1['$id'])), + Permission::delete(Role::user($user1['$id'])), + Permission::write(Role::user($user1['$id'])), + ]; + + // Create a presence for user1 using a presence-scoped API key so we can set ACLs. + $presenceAllow = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ]), + [ + 'userId' => $user1['$id'], + 'status' => 'online', + 'metadata' => ['case' => 'allow'], + // Owner always retains full permissions; user2 additionally gets access. + 'permissions' => \array_merge($permissionsForUser1, $permissionsForUser2), + ] + ); + + $this->assertEquals(200, $presenceAllow['headers']['status-code']); + $presenceIdAllow = $presenceAllow['body']['$id']; + + // user2 can read + $get = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdAllow, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(200, $get['headers']['status-code']); + + // user2 can update + $patch = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdAllow, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2), + [ + 'status' => 'busy', + 'metadata' => ['case' => 'allow-update'], + ] + ); + $this->assertEquals(200, $patch['headers']['status-code']); + $this->assertEquals('busy', $patch['body']['status']); + + // user2 can delete + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceIdAllow, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(204, $delete['headers']['status-code']); + + // Create another presence for user1 without granting any special permissions to user2. + $presenceDeny = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ]), + [ + 'userId' => $user1['$id'], + 'status' => 'online', + 'metadata' => ['case' => 'deny'], + // Only the owner has permissions; user2 should not be able to access this document. + 'permissions' => $permissionsForUser1, + ] + ); + + $this->assertEquals(200, $presenceDeny['headers']['status-code']); + $presenceIdDeny = $presenceDeny['body']['$id']; + + // user2 cannot read + $getDeny = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdDeny, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + // When read permission is missing, the document should be treated as not found. + $this->assertEquals(404, $getDeny['headers']['status-code']); + + // user2 cannot update + $patchDeny = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdDeny, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2), + [ + 'status' => 'busy', + ] + ); + $this->assertEquals(404, $patchDeny['headers']['status-code']); + + // user2 cannot delete + $deleteDeny = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceIdDeny, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(404, $deleteDeny['headers']['status-code']); + } + + public function testUpdatePresenceSparseFields(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'away', + 'metadata' => ['source' => 'setup'], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $presence = $this->resolvePresenceForUser( + $upsert['body']['userId'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + $presenceId = $presence['$id']; + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceId, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'busy', + 'metadata' => ['source' => 'update'], + ] + ); + + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals('busy', $update['body']['status']); + $this->assertEquals(['source' => 'update'], $update['body']['metadata']); + + return; + } + + $presence = $this->setupPresence([ + 'status' => 'away', + 'metadata' => ['source' => 'setup'], + ]); + + $payload = [ + 'status' => 'busy', + 'metadata' => ['source' => 'update'], + ]; + + if ($this->getSide() === 'server') { + $payload['userId'] = $presence['userId']; + } + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()), + $payload + ); + + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals('busy', $update['body']['status']); + $this->assertEquals(['source' => 'update'], $update['body']['metadata']); + } + + public function testUpdatePresenceUserIdReassignsDefaultPermissions(): void + { + if ($this->getSide() !== 'server') { + $this->expectNotToPerformAssertions(); + return; + } + + $projectId = $this->getProject()['$id']; + $user1 = $this->getUser(true); + $user2 = $this->getUser(true); + + $headersUser1 = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user1['session'], + ]; + + $headersUser2 = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'], + ]; + + $create = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser1), + [ + 'status' => 'online', + 'metadata' => ['owner' => 'user1'], + ] + ); + + $this->assertEquals(200, $create['headers']['status-code']); + $presence = $this->resolvePresenceForUser( + $user1['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser1) + ); + + $reassign = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getPresenceServerHeaders()), + [ + 'userId' => $user2['$id'], + 'status' => 'busy', + ] + ); + + $this->assertEquals(200, $reassign['headers']['status-code']); + $this->assertSame($user2['$id'], $reassign['body']['userId']); + + $getOldOwner = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser1) + ); + $this->assertEquals(404, $getOldOwner['headers']['status-code']); + + $getNewOwner = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(200, $getNewOwner['headers']['status-code']); + $this->assertSame($user2['$id'], $getNewOwner['body']['userId']); + + $patchOldOwner = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser1), + [ + 'status' => 'offline', + ] + ); + $this->assertEquals(404, $patchOldOwner['headers']['status-code']); + + $patchNewOwner = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2), + [ + 'status' => 'away', + ] + ); + $this->assertEquals(200, $patchNewOwner['headers']['status-code']); + $this->assertSame('away', $patchNewOwner['body']['status']); + } + + public function testDeletePresence(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'temp-delete', + 'metadata' => ['cleanup' => true], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $presence = $this->resolvePresenceForUser( + $upsert['body']['userId'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + $presenceId = $presence['$id']; + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceId, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + + $this->assertEquals(204, $delete['headers']['status-code']); + + return; + } + + $presence = $this->setupPresence([ + 'status' => 'temp-delete', + 'metadata' => ['cleanup' => true], + ]); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presence['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()) + ); + + $this->assertEquals(204, $delete['headers']['status-code']); + } + + public function testUpdatePresencePurgeListCache(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'cache-update-setup', + 'metadata' => ['cache' => 'update-setup'], + ] + ); + $this->assertEquals(200, $upsert['headers']['status-code']); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); + } else { + $presence = $this->setupPresence([ + 'status' => 'cache-update-setup', + 'metadata' => ['cache' => 'update-setup'], + ]); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()); + } + + $listPayload = [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + 'ttl' => 60, + ]; + + $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']); + + $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']); + $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']); + + $updatePayload = [ + 'status' => 'cache-update-applied', + 'purge' => true, + ]; + + if ($this->getSide() !== 'client') { + $updatePayload['userId'] = $presence['userId']; + } + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + $headers, + $updatePayload + ); + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals('cache-update-applied', $update['body']['status']); + + $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list3['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']); + $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']); + } + + public function testUpdatePresencePurgeOnlyListCache(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'cache-purge-only-setup', + 'metadata' => ['cache' => 'purge-only-setup'], + ] + ); + $this->assertEquals(200, $upsert['headers']['status-code']); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); + } else { + $presence = $this->setupPresence([ + 'status' => 'cache-purge-only-setup', + 'metadata' => ['cache' => 'purge-only-setup'], + ]); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()); + } + + $listPayload = [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + 'ttl' => 60, + ]; + + $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']); + + $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']); + $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']); + + $updatePayload = [ + 'purge' => true, + ]; + + if ($this->getSide() !== 'client') { + $updatePayload['userId'] = $presence['userId']; + } + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + $headers, + $updatePayload + ); + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals($presence['$id'], $update['body']['$id']); + + $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list3['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']); + $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']); + } + + public function testDeletePresencePurgesListCache(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'cache-delete-setup', + 'metadata' => ['cache' => 'delete-setup'], + ] + ); + $this->assertEquals(200, $upsert['headers']['status-code']); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); + } else { + $presence = $this->setupPresence([ + 'status' => 'cache-delete-setup', + 'metadata' => ['cache' => 'delete-setup'], + ]); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()); + } + + $listPayload = [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + 'ttl' => 60, + ]; + + $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']); + + $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']); + $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presence['$id'], + $headers + ); + $this->assertEquals(204, $delete['headers']['status-code']); + + $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list3['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']); + $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']); + } + + public function testUpdateNotFound(): void + { + if ($this->getSide() === 'client') { + $response = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'ghost', + ] + ); + + $this->assertEquals(404, $response['headers']['status-code']); + return; + } + + $payload = [ + 'status' => 'ghost', + ]; + + if ($this->getSide() === 'server') { + $payload['userId'] = $this->getUser()['$id']; + } + + $response = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()), + $payload + ); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + public function testClientCannotPassUserId(): void + { + if ($this->getSide() === 'server') { + $this->expectNotToPerformAssertions(); + return; + } + + $response = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'userId' => ID::unique(), + 'status' => 'online', + ] + ); + + $this->assertEquals(401, $response['headers']['status-code']); + } + + public function testServerRequiresUserId(): void + { + if ($this->getSide() === 'client') { + $this->expectNotToPerformAssertions(); + return; + } + + $response = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()), + [ + 'status' => 'online', + ] + ); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testUpsertSameUserMaintainsSinglePresence(): void + { + if ($this->getSide() === 'client') { + $this->expectNotToPerformAssertions(); + return; + } + + $projectId = $this->getProject()['$id']; + $userId = $this->getUser()['$id']; + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getPresenceServerHeaders()); + + $firstUpsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + $headers, + [ + 'userId' => $userId, + 'status' => 'online', + 'metadata' => ['source' => 'first-upsert'], + ] + ); + $this->assertEquals(200, $firstUpsert['headers']['status-code']); + + $secondUpsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + $headers, + [ + 'userId' => $userId, + 'status' => 'away', + 'metadata' => ['source' => 'second-upsert'], + ] + ); + $this->assertEquals(200, $secondUpsert['headers']['status-code']); + + $this->assertEquals('away', $secondUpsert['body']['status']); + $this->assertEquals(['source' => 'second-upsert'], $secondUpsert['body']['metadata']); + + $list = $this->client->call( + Client::METHOD_GET, + '/presences', + $headers, + [ + 'queries' => [ + Query::equal('userId', [$userId])->toString(), + ], + ] + ); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['presences']); + $this->assertEquals($userId, $list['body']['presences'][0]['userId']); + $this->assertEquals('away', $list['body']['presences'][0]['status']); + } + + /** + * Regression test for cross-user overwrite on the native-upsert path. + * + * Scenario: + * - User A has a presence row with $id = $sharedPresenceId. + * - User B (different userInternalId, no existing presence) issues an upsert that + * re-uses $sharedPresenceId. + * + * Without the ownership guard in State::upsertForUser, the second call would silently + * UPDATE A's row (because upsertDocument matches on the primary key) leaving B's data + * under A's $id. With the guard, the second call must fail with PRESENCE_ALREADY_EXISTS + * and A's row must be untouched. + */ + public function testCrossUserUpsertDoesNotOverwriteForeignPresence(): void + { + if ($this->getSide() !== 'client') { + $this->expectNotToPerformAssertions(); + return; + } + + $projectId = $this->getProject()['$id']; + $originalUser = $this->getUser(); + + $user1 = $this->getUser(true); + $user2 = $this->getUser(true); + + // Preserve the cached session for the rest of the test run. + self::$user[$projectId] = $originalUser; + + $headersUser1 = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user1['session'], + ]; + $headersUser2 = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'], + ]; + + $sharedPresenceId = ID::unique(); + + $victim = $this->client->call( + Client::METHOD_PUT, + '/presences/' . $sharedPresenceId, + $headersUser1, + [ + 'status' => 'online', + 'metadata' => ['owner' => 'user1'], + ] + ); + $this->assertEquals(200, $victim['headers']['status-code']); + $this->assertEquals($sharedPresenceId, $victim['body']['$id']); + $this->assertEquals($user1['$id'], $victim['body']['userId']); + + $attack = $this->client->call( + Client::METHOD_PUT, + '/presences/' . $sharedPresenceId, + $headersUser2, + [ + 'status' => 'online', + 'metadata' => ['owner' => 'user2'], + ] + ); + $this->assertNotEquals( + 200, + $attack['headers']['status-code'], + 'Cross-user upsert must not succeed silently. Got body: ' . \json_encode($attack['body'] ?? []) + ); + + // Verify User1's row is intact. Read via a presence-scoped API key to bypass + // any read-permission ambiguity and inspect the persisted state directly. + $check = $this->client->call( + Client::METHOD_GET, + '/presences/' . $sharedPresenceId, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ] + ); + $this->assertEquals(200, $check['headers']['status-code']); + $this->assertEquals($user1['$id'], $check['body']['userId']); + $this->assertEquals(['owner' => 'user1'], $check['body']['metadata']); + } +} diff --git a/tests/e2e/Services/Presences/PresenceConsoleClientTest.php b/tests/e2e/Services/Presences/PresenceConsoleClientTest.php new file mode 100644 index 0000000000..c3c2233256 --- /dev/null +++ b/tests/e2e/Services/Presences/PresenceConsoleClientTest.php @@ -0,0 +1,39 @@ +client->call(Client::METHOD_GET, '/presences/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'range' => '32h', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/presences/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'range' => '24h', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('24h', $response['body']['range']); + $this->assertCount(3, $response['body']); + $this->assertIsNumeric($response['body']['usersOnlineTotal']); + $this->assertIsArray($response['body']['presences']); + } +} diff --git a/tests/e2e/Services/Presences/PresenceCustomClientTest.php b/tests/e2e/Services/Presences/PresenceCustomClientTest.php new file mode 100644 index 0000000000..0679fefb00 --- /dev/null +++ b/tests/e2e/Services/Presences/PresenceCustomClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + + if (!empty(self::$presenceApiKeyCache[$projectId])) { + return self::$presenceApiKeyCache[$projectId]; + } + + self::$presenceApiKeyCache[$projectId] = $this->getNewKey([ + 'presences.read', + 'presences.write', + ]); + + return self::$presenceApiKeyCache[$projectId]; + } + + public function testExpiredPresenceDeletedByMaintenance(): void + { + $projectId = $this->getProject()['$id']; + $userId = $this->getUser()['$id']; + // Set a near-future expiry to satisfy validation, then wait until it is in the past. + $expiresAt = DateTime::format((new \DateTime())->modify('+2 seconds')); + + $createServer = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ], + [ + 'userId' => $userId, + 'status' => 'online', + 'metadata' => ['test' => 'presence-expiry'], + ] + ); + + $this->assertEquals(200, $createServer['headers']['status-code']); + $presenceIdServer = $createServer['body']['$id']; + + $expireServer = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdServer, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ], + [ + 'userId' => $userId, + 'expiresAt' => $expiresAt, + ] + ); + + $this->assertEquals(200, $expireServer['headers']['status-code']); + $this->assertEquals( + (new \DateTime($expiresAt))->getTimestamp(), + (new \DateTime($expireServer['body']['expiresAt']))->getTimestamp() + ); + + \sleep(3); + + $stdout = ''; + $stderr = ''; + $code = Console::execute('docker exec appwrite maintenance --type=trigger', '', $stdout, $stderr); + $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + + // Maintenance + delete workers are asynchronous; give extra time to observe cleanup. + $this->assertEventually(function () use ($presenceIdServer, $projectId) { + $getServer = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdServer, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ] + ); + + $this->assertEquals(404, $getServer['headers']['status-code']); + }, 30000, 1000); + } +} diff --git a/tests/e2e/Services/Presences/PresenceRealtimeClientTest.php b/tests/e2e/Services/Presences/PresenceRealtimeClientTest.php new file mode 100644 index 0000000000..d824412a51 --- /dev/null +++ b/tests/e2e/Services/Presences/PresenceRealtimeClientTest.php @@ -0,0 +1,683 @@ +getProject(true); + self::$project = $project; + + $user = $this->getUser(true); + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $project['$id'] . '=' . $user['session'], + ]; + + return [$project, $user, $headers]; + } + + private function getServerHeaders(array $project): array + { + return [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['$id'], + 'x-appwrite-key' => $this->getPresenceApiKey($project), + ]; + } + + private function getPresenceApiKey(array $project): string + { + $projectId = $project['$id']; + + if (!empty(self::$presenceApiKeyCache[$projectId])) { + return self::$presenceApiKeyCache[$projectId]; + } + + // Realtime tests validate HTTP reads of presences; those endpoints require `presences.read`. + self::$presenceApiKeyCache[$projectId] = $this->getNewKey([ + 'presences.read', + 'presences.write', + ]); + + return self::$presenceApiKeyCache[$projectId]; + } + + private function connectRealtimeAndSubscribe( + array $project, + array $headers, + array $channels = [], + int $timeout = 1 + ): WebSocketClient { + $queryString = \http_build_query([ + 'project' => $project['$id'], + ]); + + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => $timeout, + ] + ); + + $connected = \json_decode($client->receive(), true); + $this->assertSame('connected', $connected['type'] ?? null); + + if (empty($channels)) { + return $client; + } + + $client->send(\json_encode([ + 'type' => 'subscribe', + 'data' => [[ + 'channels' => $channels, + ]], + ])); + + $subscribeResponse = \json_decode($client->receive(), true); + $this->assertSame('response', $subscribeResponse['type'] ?? null); + $this->assertSame('subscribe', $subscribeResponse['data']['to'] ?? null); + $this->assertTrue($subscribeResponse['data']['success'] ?? false); + $this->assertNotEmpty($subscribeResponse['data']['subscriptions'] ?? []); + + return $client; + } + + private function receiveUntil( + WebSocketClient $client, + callable $match, + int $timeoutMs = 800, + int $pollMs = 50 + ): array { + $deadline = \microtime(true) + ($timeoutMs / 1000); + $lastMessage = []; + + while (\microtime(true) < $deadline) { + try { + $message = \json_decode($client->receive(), true); + } catch (TimeoutException) { + \usleep($pollMs * 1000); + continue; + } + + if (!\is_array($message)) { + continue; + } + + $lastMessage = $message; + if ($match($message)) { + return $message; + } + } + + $this->fail('Timed out waiting for expected websocket frame. Last frame: ' . \json_encode($lastMessage)); + } + + private function assertQuietFor(WebSocketClient $client, callable $forbidden, int $timeoutMs = 150): void + { + $deadline = \microtime(true) + ($timeoutMs / 1000); + while (\microtime(true) < $deadline) { + try { + $message = \json_decode($client->receive(), true); + } catch (TimeoutException) { + continue; + } + + if (!\is_array($message)) { + continue; + } + + if ($forbidden($message)) { + $this->fail('Received forbidden websocket frame: ' . \json_encode($message)); + } + } + } + + private function assertPresenceRealtimeEvent( + array $event, + string $presenceId, + string $action, + string $status, + array $metadata, + string $expectedUserId + ): void { + $this->assertSame('event', $event['type'] ?? null); + $this->assertContains('presences', $event['data']['channels'] ?? []); + $this->assertContains('presences.' . $presenceId, $event['data']['channels'] ?? []); + $this->assertContains('presences.' . $presenceId . '.' . $action, $event['data']['events'] ?? []); + $this->assertSame($presenceId, $event['data']['payload']['$id'] ?? null); + $this->assertSame($status, $event['data']['payload']['status'] ?? null); + $this->assertSame($metadata, $event['data']['payload']['metadata'] ?? []); + $this->assertSame($expectedUserId, $event['data']['payload']['userId'] ?? null); + } + + private function receivePresenceEvent( + WebSocketClient $client, + string $presenceId, + string $action, + string $status, + array $metadata, + string $expectedUserId, + int $timeoutMs = 2500 + ): array { + $event = $this->receiveUntil( + $client, + fn (array $message): bool => ($message['type'] ?? null) === 'event' + && ($message['data']['payload']['$id'] ?? null) === $presenceId + && \in_array('presences.' . $presenceId . '.' . $action, $message['data']['events'] ?? [], true), + $timeoutMs + ); + + $this->assertPresenceRealtimeEvent($event, $presenceId, $action, $status, $metadata, $expectedUserId); + return $event; + } + + private function collectPresenceOutcome( + WebSocketClient $client, + string $presenceId, + string $expectedStatus, + array $expectedMetadata, + string $expectedUserId + ): void { + $response = null; + $event = null; + + $this->receiveUntil($client, function (array $message) use ( + &$response, + &$event, + $presenceId, + $expectedStatus, + $expectedMetadata, + $expectedUserId + ): bool { + $type = $message['type'] ?? null; + if ($type === 'response' && ($message['data']['to'] ?? null) === 'presence') { + if (($message['data']['presence']['$id'] ?? null) !== $presenceId) { + return false; + } + $this->assertSame($expectedStatus, $message['data']['presence']['status'] ?? null); + $this->assertSame($expectedMetadata, $message['data']['presence']['metadata'] ?? null); + $response = $message; + } + + if ($type === 'event' && ($message['data']['payload']['$id'] ?? null) === $presenceId) { + if (!\in_array('presences.' . $presenceId . '.upsert', $message['data']['events'] ?? [], true)) { + return false; + } + $this->assertPresenceRealtimeEvent($message, $presenceId, 'upsert', $expectedStatus, $expectedMetadata, $expectedUserId); + $event = $message; + } + + return $response !== null && $event !== null; + }, 2500); + } + + private function receiveErrorMessage(WebSocketClient $client): array + { + $error = $this->receiveUntil( + $client, + fn (array $message): bool => ($message['type'] ?? null) === 'error', + 3000 + ); + $this->assertSame('error', $error['type'] ?? null); + return $error; + } + + private function sendPresenceMessage( + WebSocketClient $client, + string $presenceId, + string $status, + array $metadata, + array $permissions + ): void { + $client->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'presenceId' => $presenceId, + 'status' => $status, + 'metadata' => $metadata, + 'permissions' => $permissions, + ], + ])); + } + + private function getPresencePermissions(string|Role $readRole): array + { + return [ + Permission::read($readRole), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + public function testPresenceUpsertSenderGetsResponseAndEvent(): void + { + [$project, $user, $headers] = $this->bootstrapIsolatedProject(); + $presenceId = ID::unique(); + $metadata = ['testRunId' => ID::unique(), 'case' => 'upsert-basic']; + + $publisher = $this->connectRealtimeAndSubscribe( + $project, + $headers, + ['presences', 'presences.' . $presenceId], + timeout: 2 + ); + + try { + $this->sendPresenceMessage( + $publisher, + $presenceId, + 'online', + $metadata, + $this->getPresencePermissions(Role::any()) + ); + + $this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']); + + $read = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceId, + $this->getServerHeaders($project) + ); + + $this->assertSame(200, $read['headers']['status-code']); + $this->assertSame($presenceId, $read['body']['$id']); + $this->assertSame($user['$id'], $read['body']['userId']); + $this->assertSame('online', $read['body']['status']); + $this->assertSame($metadata, $read['body']['metadata']); + } finally { + $publisher->close(); + } + } + + public function testPresenceUpsertSameUserUpdatesSingleRecord(): void + { + [$project, $user, $headers] = $this->bootstrapIsolatedProject(); + $firstPresenceId = ID::unique(); + $secondPresenceId = ID::unique(); + $marker = ID::unique(); + + $publisher = $this->connectRealtimeAndSubscribe( + $project, + $headers, + ['presences', 'presences.' . $firstPresenceId, 'presences.' . $secondPresenceId], + timeout: 2 + ); + + try { + $firstMetadata = ['testRunId' => $marker, 'step' => 'first']; + $secondMetadata = ['testRunId' => $marker, 'step' => 'second']; + + $this->sendPresenceMessage( + $publisher, + $firstPresenceId, + 'away', + $firstMetadata, + $this->getPresencePermissions(Role::any()) + ); + $this->collectPresenceOutcome($publisher, $firstPresenceId, 'away', $firstMetadata, $user['$id']); + + $this->sendPresenceMessage( + $publisher, + $secondPresenceId, + 'busy', + $secondMetadata, + $this->getPresencePermissions(Role::any()) + ); + // The server keeps one row per user keyed by userInternalId and anchors $id to the + // first claim, so the second upsert's response/event come back under $firstPresenceId. + $this->collectPresenceOutcome($publisher, $firstPresenceId, 'busy', $secondMetadata, $user['$id']); + + $list = $this->client->call( + Client::METHOD_GET, + '/presences', + $this->getServerHeaders($project), + [ + 'queries' => [ + Query::equal('userId', [$user['$id']])->toString(), + ], + ] + ); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(1, $list['body']['total']); + $this->assertSame($user['$id'], $list['body']['presences'][0]['userId']); + $this->assertSame('busy', $list['body']['presences'][0]['status']); + $this->assertSame($secondMetadata, $list['body']['presences'][0]['metadata']); + } finally { + $publisher->close(); + } + } + + public function testPresenceValidationErrorsReturnErrorOnly(): void + { + [$project, , $headers] = $this->bootstrapIsolatedProject(); + $presenceId = ID::unique(); + $client = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 2); + + try { + $client->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'presenceId' => $presenceId, + 'metadata' => [ + 'testRunId' => ID::unique(), + ], + ], + ])); + $missingStatus = $this->receiveErrorMessage($client); + $this->assertStringContainsString('Payload is not valid. Status is required', $missingStatus['data']['message'] ?? ''); + $this->assertQuietFor( + $client, + fn (array $frame): bool => ($frame['type'] ?? null) === 'event' + && ($frame['data']['payload']['$id'] ?? null) === $presenceId + ); + + $client->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'presenceId' => $presenceId, + 'status' => 'online', + 'permissions' => 'invalid', + ], + ])); + $invalidPermissions = $this->receiveErrorMessage($client); + $this->assertStringContainsString('permissions: Permissions must be an array of strings', $invalidPermissions['data']['message'] ?? ''); + $this->assertQuietFor( + $client, + fn (array $frame): bool => ($frame['type'] ?? null) === 'event' + && ($frame['data']['payload']['$id'] ?? null) === $presenceId + ); + } finally { + $client->close(); + } + } + + public function testPresenceUnauthenticatedUserGetsAuthorizationError(): void + { + $project = $this->getProject(true); + self::$project = $project; + + $presenceId = ID::unique(); + $client = $this->connectRealtimeAndSubscribe( + $project, + ['origin' => 'http://localhost'], + ['presences', 'presences.' . $presenceId], + timeout: 2 + ); + + try { + $client->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'presenceId' => $presenceId, + 'status' => 'online', + 'metadata' => ['testRunId' => ID::unique()], + ], + ])); + + $error = $this->receiveErrorMessage($client); + $this->assertSame(401, $error['data']['code'] ?? null); + $this->assertSame('User must be authorized', $error['data']['message'] ?? null); + + $this->assertQuietFor( + $client, + fn (array $frame): bool => ($frame['type'] ?? null) === 'event' + && ($frame['data']['payload']['$id'] ?? null) === $presenceId + ); + } finally { + $client->close(); + } + } + + public function testChannelParsingChannelsAndEvents(): void + { + [$project, $user, $headers] = $this->bootstrapIsolatedProject(); + $presenceId = ID::unique(); + $listener = $this->connectRealtimeAndSubscribe( + $project, + $headers, + ['presences', 'presences.' . $presenceId], + timeout: 2 + ); + + try { + $createMetadata = ['testRunId' => ID::unique(), 'source' => 'channel-create']; + $updateMetadata = ['testRunId' => $createMetadata['testRunId'], 'source' => 'channel-update']; + + $create = $this->client->call( + Client::METHOD_PUT, + '/presences/' . $presenceId, + $this->getServerHeaders($project), + [ + 'userId' => $user['$id'], + 'status' => 'online', + 'metadata' => $createMetadata, + 'permissions' => $this->getPresencePermissions(Role::any()), + ] + ); + $this->assertSame(200, $create['headers']['status-code']); + $this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $createMetadata, $user['$id']); + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceId, + $this->getServerHeaders($project), + [ + 'status' => 'away', + 'metadata' => $updateMetadata, + ] + ); + $this->assertSame(200, $update['headers']['status-code']); + $this->receivePresenceEvent($listener, $presenceId, 'update', 'away', $updateMetadata, $user['$id']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceId, + $this->getServerHeaders($project) + ); + $this->assertSame(204, $delete['headers']['status-code']); + $this->receivePresenceEvent($listener, $presenceId, 'delete', 'away', $updateMetadata, $user['$id']); + } finally { + $listener->close(); + } + } + + public function testPresencePermissionsReceiverRouting(): void + { + [$project, $user1, $user1Headers] = $this->bootstrapIsolatedProject(); + $user2 = $this->getUser(true); + + $user2Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $project['$id'] . '=' . $user2['session'], + ]; + + $presenceIdAny = ID::unique(); + $presenceIdOwner = ID::unique(); + + $channels = [ + 'presences', + 'presences.' . $presenceIdAny, + 'presences.' . $presenceIdOwner, + ]; + + $publisher = $this->connectRealtimeAndSubscribe($project, $user1Headers, ['presences'], timeout: 1); + $listener1 = $this->connectRealtimeAndSubscribe($project, $user1Headers, $channels, timeout: 1); + $listener2 = $this->connectRealtimeAndSubscribe($project, $user2Headers, $channels, timeout: 1); + + try { + $metadataAny = ['testRunId' => ID::unique(), 'visibility' => 'any']; + $this->sendPresenceMessage( + $publisher, + $presenceIdAny, + 'online', + $metadataAny, + $this->getPresencePermissions(Role::any()) + ); + $this->collectPresenceOutcome($publisher, $presenceIdAny, 'online', $metadataAny, $user1['$id']); + $this->receivePresenceEvent($listener1, $presenceIdAny, 'upsert', 'online', $metadataAny, $user1['$id']); + $this->receivePresenceEvent($listener2, $presenceIdAny, 'upsert', 'online', $metadataAny, $user1['$id']); + + $metadataOwner = ['testRunId' => ID::unique(), 'visibility' => 'owner']; + $this->sendPresenceMessage( + $publisher, + $presenceIdOwner, + 'busy', + $metadataOwner, + $this->getPresencePermissions(Role::user($user1['$id'])) + ); + // Same user, so the server reuses the original record's $id ($presenceIdAny); + // only permissions/status/metadata change — which is what permission routing should filter on. + $this->collectPresenceOutcome($publisher, $presenceIdAny, 'busy', $metadataOwner, $user1['$id']); + $this->receivePresenceEvent($listener1, $presenceIdAny, 'upsert', 'busy', $metadataOwner, $user1['$id']); + $this->assertQuietFor( + $listener2, + fn (array $frame): bool => ($frame['type'] ?? null) === 'event' + && ($frame['data']['payload']['$id'] ?? null) === $presenceIdAny + && ($frame['data']['payload']['metadata']['visibility'] ?? null) === 'owner' + ); + } finally { + $publisher->close(); + $listener1->close(); + $listener2->close(); + } + } + + public function testPresenceCloseEmitsDeleteEvent(): void + { + [$project, $user, $headers] = $this->bootstrapIsolatedProject(); + $presenceId = ID::unique(); + $metadata = ['testRunId' => ID::unique(), 'source' => 'close-delete']; + + $publisher = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1); + $listener = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1); + + try { + $this->sendPresenceMessage( + $publisher, + $presenceId, + 'online', + $metadata, + $this->getPresencePermissions(Role::any()) + ); + $this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']); + $this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $metadata, $user['$id']); + + $publisher->close(); + + $this->receivePresenceEvent($listener, $presenceId, 'delete', 'online', $metadata, $user['$id'], timeoutMs: 3000); + } finally { + $listener->close(); + } + } + + public function testHttpDeleteThenCloseDoesNotDuplicateDeleteEvent(): void + { + [$project, $user, $headers] = $this->bootstrapIsolatedProject(); + $presenceId = ID::unique(); + $metadata = ['testRunId' => ID::unique(), 'source' => 'http-delete-then-close']; + + $publisher = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1); + $listener = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1); + + try { + // Publish a presence over WebSocket so the realtime worker tracks it in + // its in-memory connection map under the publisher connection. + $this->sendPresenceMessage( + $publisher, + $presenceId, + 'online', + $metadata, + $this->getPresencePermissions(Role::any()) + ); + $this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']); + $this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $metadata, $user['$id']); + + // HTTP DELETE removes the row from the DB and emits the delete event via pubsub. + // The realtime worker is expected to strip the presence from the publisher's + // in-memory connection state when it processes the pubsub message. + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceId, + $this->getServerHeaders($project) + ); + $this->assertSame(204, $delete['headers']['status-code']); + + // Synchronization point: wait for the listener to receive the legitimate + // delete event before closing the publisher. Redis pubsub broadcasts to + // every realtime worker simultaneously, so the listener's worker observing + // the event implies the publisher's worker has also processed it (and run + // the in-memory cleanup) by the time onClose fires. + $deleteEvents = []; + $deleteEvents[] = $this->receivePresenceEvent($listener, $presenceId, 'delete', 'online', $metadata, $user['$id']); + + $publisher->close(); + + // Watch for any additional presences.{id}.delete frame. A second one would + // be the regression: onClose re-firing the event for a presence already + // removed via HTTP DELETE. + $deadline = \microtime(true) + 2.0; + + $this->assertEventually( + function () use ($listener, $presenceId, $deadline, &$deleteEvents): void { + try { + $raw = $listener->receive(); + $frame = \json_decode($raw, true); + if ( + \is_array($frame) + && ($frame['type'] ?? null) === 'event' + && ($frame['data']['payload']['$id'] ?? null) === $presenceId + && \in_array('presences.' . $presenceId . '.delete', $frame['data']['events'] ?? [], true) + ) { + $deleteEvents[] = $frame; + if (\count($deleteEvents) > 1) { + throw new Critical( + 'Duplicate presence delete event after HTTP DELETE + WebSocket close: ' + . \json_encode($frame) + ); + } + } + } catch (TimeoutException) { + // No frame this poll; fall through to deadline check. + } + + if (\microtime(true) < $deadline) { + // Throw a non-Critical exception so assertEventually retries. + throw new \RuntimeException('still watching for duplicate delete event'); + } + }, + timeoutMs: 3000, + waitMs: 0 + ); + + $this->assertCount( + 1, + $deleteEvents, + 'Expected exactly one presences.' . $presenceId . '.delete event; got ' . \count($deleteEvents) + ); + $this->assertPresenceRealtimeEvent($deleteEvents[0], $presenceId, 'delete', 'online', $metadata, $user['$id']); + } finally { + $listener->close(); + } + } +} diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 748fb3502b..19355bdce0 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -294,6 +294,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { + // Empty sender name is valid — PHPMailer accepts '' as display name. $response = $this->updateSMTP( senderName: '', senderEmail: 'sender@example.com', @@ -301,11 +302,17 @@ trait SMTPBase port: 1025, ); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); } public function testUpdateSMTPEmptySenderEmail(): void { + // Empty senderEmail clears the stored value; connection test is skipped when + // there is no valid From address, so this is accepted even without enabled=false. $response = $this->updateSMTP( senderName: 'Test', senderEmail: '', @@ -313,7 +320,11 @@ trait SMTPBase port: 1025, ); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSenderEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); } public function testUpdateSMTPEmptyHost(): void @@ -353,6 +364,59 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); } + public function testUpdateSMTPReplyToEmailCanBeCleared(): void + { + // Step 1: Set a custom replyToEmail. + $set = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'reply@example.com', + ); + $this->assertSame(200, $set['headers']['status-code']); + $this->assertSame('reply@example.com', $set['body']['smtpReplyToEmail']); + + // Step 2: Clear it with an empty string. + $clear = $this->updateSMTP(replyToEmail: ''); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['smtpReplyToEmail']); + + // Step 3: Verify the cleared value persists. + $verify = $this->updateSMTP(); + $this->assertSame(200, $verify['headers']['status-code']); + $this->assertSame('', $verify['body']['smtpReplyToEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderEmailCanBeClearedWhenDisabled(): void + { + // Step 1: Configure SMTP with a sender email, then disable it. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // Step 2: Clear senderEmail while keeping SMTP disabled. + // enabled=false skips the PHPMailer connection check so empty senderEmail is valid. + $clear = $this->updateSMTP( + senderEmail: '', + enabled: false, + ); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['smtpSenderEmail']); + + // Step 3: Verify the cleared value persists. + $verify = $this->updateSMTP(enabled: false); + $this->assertSame(200, $verify['headers']['status-code']); + $this->assertSame('', $verify['body']['smtpSenderEmail']); + } + public function testUpdateSMTPInvalidSecure(): void { $response = $this->updateSMTP( @@ -461,6 +525,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { + // Empty string clears a previously-set username (no-auth SMTP). $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', @@ -469,7 +534,11 @@ trait SMTPBase username: '', ); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordMinLength(): void @@ -524,6 +593,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { + // Empty string clears a previously-set password (no-auth SMTP). $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', @@ -532,7 +602,45 @@ trait SMTPBase password: '', ); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only and never echoed back. + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPCredentialsCanBeCleared(): void + { + // Step 1: Set username and password. + $set = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'myuser', + password: 'mypassword', + ); + $this->assertSame(200, $set['headers']['status-code']); + $this->assertSame('myuser', $set['body']['smtpUsername']); + + // Step 2: Clear both credentials by passing empty strings. + $clear = $this->updateSMTP( + username: '', + password: '', + ); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['smtpUsername']); + // smtpPassword is write-only and never echoed back regardless. + $this->assertSame('', $clear['body']['smtpPassword']); + + // Step 3: Verify the cleared username persists (a no-params PATCH must not restore it). + $verify = $this->updateSMTP(); + $this->assertSame(200, $verify['headers']['status-code']); + $this->assertSame('', $verify['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithoutSecure(): void diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 11dc6dc80b..9e329dfc3b 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -548,6 +548,59 @@ trait TemplatesBase $this->assertSame(401, $response['headers']['status-code']); } + public function testUpdateEmailTemplateSenderFieldsCanBeCleared(): void + { + $this->ensureSMTPEnabled(); + + // Step 1: Set a custom en verification template with sender and reply-to fields. + $first = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Verify your email', + message: 'Please verify: {{url}}', + senderName: 'Custom Sender', + senderEmail: 'custom-sender@appwrite.io', + replyToName: 'Custom Reply', + replyToEmail: 'custom-reply@appwrite.io', + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame('Custom Sender', $first['body']['senderName']); + $this->assertSame('custom-sender@appwrite.io', $first['body']['senderEmail']); + $this->assertSame('Custom Reply', $first['body']['replyToName']); + $this->assertSame('custom-reply@appwrite.io', $first['body']['replyToEmail']); + + // Step 2: GET en verification template and ensure it reflects the custom values. + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Custom Sender', $get['body']['senderName']); + $this->assertSame('custom-sender@appwrite.io', $get['body']['senderEmail']); + $this->assertSame('Custom Reply', $get['body']['replyToName']); + $this->assertSame('custom-reply@appwrite.io', $get['body']['replyToEmail']); + + // Step 3: Update the same template, clearing sender and reply-to fields to empty strings. + $clear = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + senderName: '', + senderEmail: '', + replyToName: '', + replyToEmail: '', + ); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['senderName']); + $this->assertSame('', $clear['body']['senderEmail']); + $this->assertSame('', $clear['body']['replyToName']); + $this->assertSame('', $clear['body']['replyToEmail']); + + // Step 4: GET again to confirm the cleared values persist. + $getAfter = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $getAfter['headers']['status-code']); + $this->assertSame('', $getAfter['body']['senderName']); + $this->assertSame('', $getAfter['body']['senderEmail']); + $this->assertSame('', $getAfter['body']['replyToName']); + $this->assertSame('', $getAfter['body']['replyToEmail']); + } + public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. diff --git a/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php b/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php index 0f1ff7eab3..8e98c8bb48 100644 --- a/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php +++ b/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php @@ -118,7 +118,7 @@ trait WebhooksBase $this->assertCount(2, $collection['body']['attributes']); $this->assertEquals('available', $collection['body']['attributes'][0]['status']); $this->assertEquals('available', $collection['body']['attributes'][1]['status']); - }, 15000, 500); + }, 60000, 500); return ['databaseId' => $databaseId, 'actorsId' => $actorsId]; } @@ -192,7 +192,7 @@ trait WebhooksBase $this->assertCount(2, $table['body']['columns']); $this->assertEquals('available', $table['body']['columns'][0]['status']); $this->assertEquals('available', $table['body']['columns'][1]['status']); - }, 15000, 500); + }, 60000, 500); return ['databaseId' => $databaseId, 'actorsId' => $actorsId]; } @@ -529,7 +529,7 @@ trait WebhooksBase $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); $this->assertNotEmpty($webhook['data']['key']); $this->assertEquals($webhook['data']['key'], 'extra'); - }, 15000, 500); + }, 30000, 500); $removed = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/' . $extra['body']['key'], array_merge([ 'content-type' => 'application/json', @@ -896,7 +896,7 @@ trait WebhooksBase $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); $this->assertNotEmpty($webhook['data']['key']); $this->assertEquals($webhook['data']['key'], 'extra'); - }, 15000, 500); + }, 30000, 500); $removed = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/' . $extra['body']['key'], array_merge([ 'content-type' => 'application/json', @@ -1833,6 +1833,6 @@ trait WebhooksBase // assert that the webhook is now disabled after 10 consecutive failures $this->assertEquals($webhook['body']['enabled'], false); $this->assertEquals($webhook['body']['attempts'], 10); - }, 15000, 500); + }, 30000, 500); } } diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php index c83958afe1..d0e4c0d793 100644 --- a/tests/e2e/Services/Proxy/ProxyBase.php +++ b/tests/e2e/Services/Proxy/ProxyBase.php @@ -256,6 +256,7 @@ trait ProxyBase $this->assertEquals(200, $rules['headers']['status-code']); $this->assertEquals(2, $rules['body']['total']); + // Delete rules before the site to avoid cascade-delete races. $this->cleanupRule($ruleId301); $this->cleanupRule($ruleId307); $this->cleanupSite($siteId); diff --git a/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php b/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php index 3da00898c9..e8946e54d5 100644 --- a/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php @@ -257,7 +257,7 @@ class RealtimeConsoleClientTest extends Scope $this->assertEquals('error', $response['type']); $this->assertNotEmpty($response['data']); $this->assertEquals(1003, $response['data']['code']); - $this->assertEquals('Payload is not valid.', $response['data']['message']); + $this->assertEquals('Payload is not valid. Session is required', $response['data']['message']); $client->send(\json_encode([ 'type' => 'unknown', diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 813ef70ff0..7fccd6839f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -239,7 +239,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('error', $response['type']); $this->assertNotEmpty($response['data']); $this->assertEquals(1003, $response['data']['code']); - $this->assertEquals('Payload is not valid.', $response['data']['message']); + $this->assertEquals('Payload is not valid. Session is required', $response['data']['message']); $client->send(\json_encode([ 'type' => 'unknown', diff --git a/tests/unit/Advisor/AuthTest.php b/tests/unit/Advisor/AuthTest.php new file mode 100644 index 0000000000..c2d4a93755 --- /dev/null +++ b/tests/unit/Advisor/AuthTest.php @@ -0,0 +1,37 @@ +getLabels()['sdk']; + + $this->assertSame([AuthType::ADMIN, AuthType::KEY], $method->getAuth()); + } + + public static function advisorActionsProvider(): array + { + return [ + 'get report' => [new GetReport()], + 'list reports' => [new ListReports()], + 'delete report' => [new DeleteReport()], + 'get insight' => [new GetInsight()], + 'list insights' => [new ListInsights()], + ]; + } +} diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index bf901bbe43..12c99a83ef 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -1025,4 +1025,105 @@ class MessagingTest extends TestCase $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); } } + + public function testFromPayloadPresenceChannels(): void + { + $presenceId = ID::custom('presence123'); + + $result = Realtime::fromPayload( + event: 'presences.' . $presenceId . '.upsert', + payload: new Document([ + '$id' => $presenceId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + ]), + ); + + $this->assertContains('presences', $result['channels']); + $this->assertContains('presences.' . $presenceId, $result['channels']); + $this->assertContains(Role::any()->toString(), $result['roles']); + } + + public function testExtractDeletedPresenceIdReturnsIdForDeleteEvent(): void + { + $event = [ + 'project' => 'proj', + 'data' => [ + 'events' => [ + 'presences.abc.delete', + 'presences.*.delete', + 'presences.abc', + ], + 'payload' => ['$id' => 'abc'], + ], + ]; + + $this->assertSame('abc', Realtime::extractDeletedPresenceId($event)); + } + + public function testExtractDeletedPresenceIdRejectsNonDeleteEvents(): void + { + $this->assertNull(Realtime::extractDeletedPresenceId([ + 'data' => [ + 'events' => ['presences.abc.upsert'], + 'payload' => ['$id' => 'abc'], + ], + ])); + + // Unrelated resource that happens to end with `.delete` must not trigger. + $this->assertNull(Realtime::extractDeletedPresenceId([ + 'data' => [ + 'events' => ['documents.abc.delete'], + 'payload' => ['$id' => 'abc'], + ], + ])); + + // Missing payload ID — the event names look right but we have nothing to remove. + $this->assertNull(Realtime::extractDeletedPresenceId([ + 'data' => [ + 'events' => ['presences.abc.delete'], + 'payload' => [], + ], + ])); + } + + public function testRemovePresenceFromConnectionsScopedToProject(): void + { + $realtime = new Realtime(); + + // Two connections in different projects both holding the same presence ID; only + // the matching project should be touched. + $realtime->connections[1] = [ + 'projectId' => 'proj-a', + 'presences' => ['p1' => new Document(['$id' => 'p1']), 'p2' => new Document(['$id' => 'p2'])], + ]; + $realtime->connections[2] = [ + 'projectId' => 'proj-b', + 'presences' => ['p1' => new Document(['$id' => 'p1'])], + ]; + + $removed = $realtime->removePresenceFromConnections('proj-a', 'p1'); + + $this->assertSame(1, $removed); + $this->assertArrayNotHasKey('p1', $realtime->connections[1]['presences']); + $this->assertArrayHasKey('p2', $realtime->connections[1]['presences']); + $this->assertArrayHasKey('p1', $realtime->connections[2]['presences']); + } + + public function testRemovePresenceFromConnectionsNoMatchIsNoOp(): void + { + $realtime = new Realtime(); + $realtime->connections[1] = [ + 'projectId' => 'proj-a', + 'presences' => ['p1' => new Document(['$id' => 'p1'])], + ]; + + $this->assertSame(0, $realtime->removePresenceFromConnections('proj-a', 'missing')); + $this->assertSame(0, $realtime->removePresenceFromConnections('', 'p1')); + $this->assertSame(0, $realtime->removePresenceFromConnections('proj-a', '')); + $this->assertArrayHasKey('p1', $realtime->connections[1]['presences']); + } }