diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd2c50714..d1cc4c4f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,7 +443,8 @@ jobs: Messaging, Notifications, 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..120c9704ce 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -841,6 +841,28 @@ return [ 'array' => true, 'filters' => [], ], + [ + '$id' => ID::custom('providerBranches'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], + [ + '$id' => ID::custom('providerPaths'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ @@ -1320,6 +1342,28 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('providerBranches'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], + [ + '$id' => ID::custom('providerPaths'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ @@ -2754,4 +2798,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/cors.php b/app/config/cors.php index 0454a24495..8147ec8fe5 100644 --- a/app/config/cors.php +++ b/app/config/cors.php @@ -22,6 +22,7 @@ return [ 'X-Appwrite-Locale', 'X-Appwrite-Mode', 'X-Appwrite-JWT', + 'X-Appwrite-Organization', 'X-Appwrite-Response-Format', 'X-Appwrite-Timeout', 'X-Appwrite-ID', 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..d5ae7c1331 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', @@ -146,7 +150,7 @@ return [ 'label' => 'Owner', 'scopes' => \array_merge($member, $admins), ], - User::ROLE_APPS => [ + User::ROLE_KEYS => [ 'label' => 'Applications', 'scopes' => ['global', 'health.read', 'graphql'], ], 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/api/graphql.php b/app/controllers/api/graphql.php index 9ec2479749..4a509aefdd 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -39,7 +39,7 @@ Http::init() if ( array_key_exists('graphql', $project->getAttribute('apis', [])) && !$project->getAttribute('apis', [])['graphql'] - && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + && !($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index ccd7cf4661..abe06a2bb8 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -732,6 +732,13 @@ Http::get('/v1/users') $cursor->setValue($cursorDocument); } + $skipFilters = ['subQueryAuthenticators', 'subQuerySessions', 'subQueryTokens', 'subQueryChallenges', 'subQueryMemberships']; + + $selects = Query::getByType($queries, [Query::TYPE_SELECT]); + if (empty($selects)) { + $skipFilters[] = 'subQueryTargets'; + } + $users = []; $total = 0; @@ -744,7 +751,32 @@ Http::get('/v1/users') } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - }, ['subQueryAuthenticators', 'subQuerySessions', 'subQueryTokens', 'subQueryChallenges', 'subQueryMemberships']); + }, $skipFilters); + + if (empty($selects) && !empty($users)) { + $sequences = []; + foreach ($users as $user) { + $sequences[] = $user->getSequence(); + } + + try { + $targets = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->find('targets', [ + Query::equal('userInternalId', $sequences), + Query::limit(PHP_INT_MAX), + ])); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $targetsByUser = []; + foreach ($targets as $target) { + $targetsByUser[$target->getAttribute('userInternalId')][] = $target; + } + + foreach ($users as $user) { + $user->setAttribute('targets', $targetsByUser[$user->getSequence()] ?? []); + } + } $response->dynamic(new Document([ 'users' => $users, diff --git a/app/controllers/general.php b/app/controllers/general.php index 6ca0a63ee2..219c14774f 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', ''); @@ -859,7 +835,8 @@ Http::init() ->inject('authorization') ->inject('publisherForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) { + ->inject('params') + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount, array $params) { /* * Appwrite Router */ @@ -868,14 +845,14 @@ Http::init() // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) { - $utopia->getRoute()?->label('router', true); + $utopia->match($request)?->route->label('router', true); } } /* * Request format */ - $route = $utopia->getRoute(); + $route = $utopia->match($request)?->route; $request->setRoute($route); if ($route === null) { @@ -900,7 +877,7 @@ Http::init() } if (version_compare($requestFormat, '1.8.0', '<')) { $dbForProject = $getProjectDB($project); - $request->addFilter(new RequestV20($dbForProject, $route->getPathValues($request))); + $request->addFilter(new RequestV20($dbForProject, $params)); } if (version_compare($requestFormat, '1.9.0', '<')) { $request->addFilter(new RequestV21()); @@ -1178,7 +1155,7 @@ Http::options() // Only run Router when external domain if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) { - $utopia->getRoute()?->label('router', true); + $utopia->match($request)?->route->label('router', true); } } @@ -1213,7 +1190,7 @@ Http::error() ->inject('authorization') ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); - $route = $utopia->getRoute(); + $route = $utopia->match($request)?->route; $class = \get_class($error); $code = $error->getCode(); $message = $error->getMessage(); @@ -1579,7 +1556,7 @@ Http::get('/robots.txt') $response->text($template->render(false)); } else { if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) { - $utopia->getRoute()?->label('router', true); + $utopia->match($request)?->route->label('router', true); } } }); @@ -1613,7 +1590,7 @@ Http::get('/humans.txt') $response->text($template->render(false)); } else { if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) { - $utopia->getRoute()?->label('router', true); + $utopia->match($request)?->route->label('router', true); } } }); diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 4e92b3482d..00389085af 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -13,6 +13,7 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\UID; use Utopia\Http\Http; +use Utopia\Http\Route; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\Text; @@ -283,13 +284,11 @@ Http::get('/v1/mock/github/callback') Http::shutdown() ->groups(['mock']) - ->inject('utopia') ->inject('response') - ->inject('request') - ->action(function (Http $utopia, Response $response, Request $request) { + ->inject('route') + ->action(function (Response $response, Route $route) { $result = []; - $route = $utopia->getRoute(); $path = APP_STORAGE_CACHE . '/tests.json'; $tests = (\file_exists($path)) ? \json_decode(\file_get_contents($path), true) : []; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 6e5167660a..494bd3da28 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -37,6 +37,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\Roles; use Utopia\Http\Http; +use Utopia\Http\Route; use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; @@ -85,7 +86,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar Http::init() ->groups(['api']) - ->inject('utopia') + ->inject('route') ->inject('request') ->inject('dbForPlatform') ->inject('dbForProject') @@ -98,11 +99,7 @@ Http::init() ->inject('team') ->inject('apiKey') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { - $route = $utopia->getRoute(); - if ($route === null) { - throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); - } + ->action(function (Route $route, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { /** * Handle user authentication and session validation. @@ -178,8 +175,8 @@ Http::init() $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); - // Handle special app role case - if ($apiKey->getRole() === User::ROLE_APPS) { + // Handle special key role case + if ($apiKey->getRole() === User::ROLE_KEYS) { // Disable authorization checks for project API keys // Dynamic supported for backwards compatibility if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) { @@ -189,7 +186,7 @@ Http::init() $user = new User([ '$id' => '', 'status' => true, - 'type' => ACTIVITY_TYPE_KEY_PROJECT, + 'type' => ACTOR_TYPE_KEY_PROJECT, 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => $apiKey->getName(), @@ -261,9 +258,9 @@ Http::init() $userClone = clone $user; $userClone->setAttribute('type', match ($apiKey->getType()) { - API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT, - API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, - default => ACTIVITY_TYPE_KEY_ORGANIZATION, + API_KEY_STANDARD => ACTOR_TYPE_KEY_PROJECT, + API_KEY_ACCOUNT => ACTOR_TYPE_KEY_ACCOUNT, + default => ACTOR_TYPE_KEY_ORGANIZATION, }); $auditContext->user = $userClone; } @@ -428,7 +425,7 @@ Http::init() if ( array_key_exists($namespace, $project->getAttribute('services', [])) && ! $project->getAttribute('services', [])[$namespace] - && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles())) ) { throw new Exception(Exception::GENERAL_SERVICE_DISABLED); } @@ -438,7 +435,7 @@ Http::init() if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] - && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } @@ -477,7 +474,7 @@ Http::init() Http::init() ->groups(['api']) - ->inject('utopia') + ->inject('route') ->inject('request') ->inject('response') ->inject('project') @@ -485,21 +482,16 @@ Http::init() ->inject('timelimit') ->inject('devKey') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) { + ->action(function (Route $route, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) { $response->setUser($user); $request->setUser($user); $roles = $authorization->getRoles(); $shouldCheckAbuse = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled' - && ! $user->isApp($roles) + && ! $user->isKey($roles) && ! $user->isPrivileged($roles) && $devKey->isEmpty(); - $route = $utopia->getRoute(); - if ($route === null) { - throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); - } - $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; $closestLimit = null; @@ -556,7 +548,7 @@ Http::init() Http::init() ->groups(['api']) - ->inject('utopia') + ->inject('route') ->inject('request') ->inject('response') ->inject('project') @@ -574,17 +566,12 @@ Http::init() ->inject('platform') ->inject('authorization') ->inject('cacheControlForStorage') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { + ->action(function (Route $route, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { $response->setUser($user); $request->setUser($user); - $route = $utopia->getRoute(); - if ($route === null) { - throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); - } - - $path = $route->getMatchedPath(); + $path = $route->getPath(); $databaseType = match (true) { str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, @@ -615,7 +602,7 @@ Http::init() $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { - $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); + $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTOR_TYPE_ADMIN : ACTOR_TYPE_USER); } $auditContext->user = $userClone; } @@ -623,9 +610,8 @@ Http::init() $useCache = $route->getLabel('cache', false); $storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load'); if ($useCache) { - $route = $utopia->match($request); $roles = $authorization->getRoles(); - $isAppUser = $user->isApp($roles); + $isAppUser = $user->isKey($roles); $isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview'; $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($roles); @@ -761,12 +747,11 @@ Http::init() */ Http::shutdown() ->groups(['session']) - ->inject('utopia') ->inject('request') ->inject('response') ->inject('project') ->inject('dbForProject') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { + ->action(function (Request $request, Response $response, Document $project, Database $dbForProject) { $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0; if ($sessionLimit === 0) { @@ -800,7 +785,7 @@ Http::shutdown() Http::shutdown() ->groups(['api']) - ->inject('utopia') + ->inject('route') ->inject('request') ->inject('response') ->inject('project') @@ -820,7 +805,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Route $route, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -876,7 +861,6 @@ Http::shutdown() } } - $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); /** @@ -929,7 +913,7 @@ Http::shutdown() $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { - $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); + $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTOR_TYPE_ADMIN : ACTOR_TYPE_USER); } $auditContext->user = $userClone; } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { @@ -944,7 +928,7 @@ Http::shutdown() $user = new User([ '$id' => '', 'status' => true, - 'type' => ACTIVITY_TYPE_GUEST, + 'type' => ACTOR_TYPE_GUEST, 'email' => 'guest.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => 'Guest', diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index db98d97bf5..dfb384f893 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -9,6 +9,7 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Http\Http; +use Utopia\Http\Route; use Utopia\System\System; Http::init() @@ -32,13 +33,13 @@ Http::init() Http::init() ->groups(['auth']) - ->inject('utopia') + ->inject('route') ->inject('request') ->inject('project') ->inject('geodb') ->inject('user') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) { + ->action(function (Route $route, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) { $denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', ''); if (!empty($denylist && $project->getId() === 'console')) { $countries = explode(',', $denylist); @@ -49,10 +50,8 @@ Http::init() } } - $route = $utopia->match($request); - $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); - $isAppUser = $user->isApp($authorization->getRoles()); + $isAppUser = $user->isKey($authorization->getRoles()); if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs return; diff --git a/app/http.php b/app/http.php index 6dc415f000..6226026a16 100644 --- a/app/http.php +++ b/app/http.php @@ -539,7 +539,7 @@ $swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoo $app->run($request, $response); - $route = $app->getRoute(); + $route = $app->match($request)?->route; Span::add('http.path', $route?->getPath() ?? 'unknown'); } catch (\Throwable $th) { Span::error($th); @@ -555,7 +555,7 @@ $swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoo // All good, user is optional information for logger } - $route = $app->getRoute(); + $route = $app->match($request)?->route; $log = $app->context()->get("log"); diff --git a/app/init/constants.php b/app/init/constants.php index ffb2a1ec80..187c0b1d3a 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -159,15 +159,15 @@ const SESSION_PROVIDER_TOKEN = 'token'; const SESSION_PROVIDER_SERVER = 'server'; /** - * Activity associated with user or the app. + * Actor that performed the request (user, admin, guest, or API key). */ -const ACTIVITY_TYPE_USER = 'user'; -const ACTIVITY_TYPE_ADMIN = 'admin'; -const ACTIVITY_TYPE_GUEST = 'guest'; -const ACTIVITY_TYPE_HIDDEN = 'hidden'; -const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject'; -const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount'; -const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization'; +const ACTOR_TYPE_USER = 'user'; +const ACTOR_TYPE_ADMIN = 'admin'; +const ACTOR_TYPE_GUEST = 'guest'; +const ACTOR_TYPE_HIDDEN = 'hidden'; +const ACTOR_TYPE_KEY_PROJECT = 'keyProject'; +const ACTOR_TYPE_KEY_ACCOUNT = 'keyAccount'; +const ACTOR_TYPE_KEY_ORGANIZATION = 'keyOrganization'; /** * MFA @@ -402,6 +402,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 eedc610e1b..112344df57 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -175,6 +175,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; @@ -215,6 +216,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; @@ -238,6 +240,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)); @@ -265,7 +268,7 @@ Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIS Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT)); Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION)); -Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false)); +Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, true)); Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, true)); Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true)); Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false)); @@ -363,6 +366,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()); @@ -492,6 +496,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 22e36fe431..5237803815 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -54,6 +54,98 @@ 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_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_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('publisherForNotifications', fn (Publisher $publisher) => new NotificationPublisher( + $publisher, + new Queue(System::getEnv('_APP_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_QUEUE_NAME)) +), ['publisher']); + $container->set('logger', function ($register) { return $register->get('logger'); }, ['register']); @@ -68,88 +160,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_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_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('publisherForNotifications', fn (Publisher $publisher) => new NotificationPublisher( - $publisher, - new Queue(System::getEnv('_APP_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_QUEUE_NAME)) -), ['publisher']); - /** * Platform configuration */ @@ -157,10 +167,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(); }, []); @@ -219,8 +225,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 = []; @@ -421,5 +425,3 @@ $container->set( 'isResourceBlocked', fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false ); - -$container->set('executor', fn () => new Executor()); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 85d8db3698..8c55eaf3e0 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -596,7 +596,7 @@ return function (Container $context): void { // These endpoints moved from /v1/projects/:projectId/ to /v1/ // When accessed via the old alias path, extract projectId from the URI $deprecatedProjectPathPrefix = '/v1/projects/'; - $route = $utopia->match($request); + $route = $utopia->match($request)?->route; if (!empty($route)) { $isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) && !\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix); @@ -1093,7 +1093,7 @@ return function (Container $context): void { if ($project->getId() !== 'console') { $teamInternalId = $project->getAttribute('teamInternalId', ''); } else { - $route = $utopia->match($request); + $route = $utopia->match($request)?->route; $path = ! empty($route) ? $route->getPath() : $request->getURI(); $orgHeader = $request->getHeader('x-appwrite-organization', ''); if (str_starts_with($path, '/v1/projects/:projectId')) { diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 7806d8c600..d4c8c31748 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -344,6 +344,7 @@ return function (Container $container): void { $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..ce2dc41e54 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)) { @@ -755,7 +858,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true; if ( !$websocketEnabled - && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + && !($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } @@ -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..3ddd6ae119 100644 --- a/composer.json +++ b/composer.json @@ -54,20 +54,20 @@ "utopia-php/abuse": "1.3.*", "utopia-php/agents": "1.2.*", "utopia-php/analytics": "0.15.*", - "utopia-php/audit": "2.3.*", + "utopia-php/audit": "^2.4", "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-rc2", "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..54a6faab3a 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": "b092fffec11494aea10b0c823b7837b8", "packages": [ { "name": "adhocore/jwt", @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.20.0", + "version": "0.20.1", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3" + "reference": "e9213dfe9fff1b67de77aa61dbcae5f4ca10b6d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3", - "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/e9213dfe9fff1b67de77aa61dbcae5f4ca10b6d6", + "reference": "e9213dfe9fff1b67de77aa61dbcae5f4ca10b6d6", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.20.0" + "source": "https://github.com/appwrite/runtimes/tree/0.20.1" }, - "time": "2026-05-01T07:47:07+00:00" + "time": "2026-05-24T03:00:39+00:00" }, { "name": "brick/math", @@ -3510,16 +3510,16 @@ }, { "name": "utopia-php/audit", - "version": "2.3.2", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3" + "reference": "eddd79d93f23ed2851c0df2b1e2e2dfb25ba06c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3", - "reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/eddd79d93f23ed2851c0df2b1e2e2dfb25ba06c6", + "reference": "eddd79d93f23ed2851c0df2b1e2e2dfb25ba06c6", "shasum": "" }, "require": { @@ -3554,9 +3554,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.3.2" + "source": "https://github.com/utopia-php/audit/tree/2.4.1" }, - "time": "2026-05-14T04:00:37+00:00" + "time": "2026-05-20T06:25:45+00:00" }, { "name": "utopia-php/auth", @@ -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", @@ -4346,16 +4346,16 @@ }, { "name": "utopia-php/http", - "version": "2.0.0-rc1", + "version": "2.0.0-rc2", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "3e3b431d443844c6bf810120dee735f45880856f" + "reference": "17f3d5e966ada8a5c041717436f069f269aef2b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/3e3b431d443844c6bf810120dee735f45880856f", - "reference": "3e3b431d443844c6bf810120dee735f45880856f", + "url": "https://api.github.com/repos/utopia-php/http/zipball/17f3d5e966ada8a5c041717436f069f269aef2b3", + "reference": "17f3d5e966ada8a5c041717436f069f269aef2b3", "shasum": "" }, "require": { @@ -4396,9 +4396,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/2.0.0-rc1" + "source": "https://github.com/utopia-php/http/tree/2.0.0-rc2" }, - "time": "2026-05-05T15:00:03+00:00" + "time": "2026-05-20T11:13:49+00:00" }, { "name": "utopia-php/image", @@ -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", @@ -5355,16 +5355,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.3", + "version": "0.2.4", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "9770269c8ed8e6909934965fa8722103c7434c23" + "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/9770269c8ed8e6909934965fa8722103c7434c23", - "reference": "9770269c8ed8e6909934965fa8722103c7434c23", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/b4ee60db4dbae5ffbe53968d01f69b6941251576", + "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576", "shasum": "" }, "require": { @@ -5394,28 +5394,28 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.3" + "source": "https://github.com/utopia-php/validators/tree/0.2.4" }, - "time": "2026-05-14T08:05:44+00:00" + "time": "2026-05-21T12:47:43+00:00" }, { "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/references/projects/create-jwt.md b/docs/references/projects/create-jwt.md deleted file mode 100644 index 9a6f8ebf6b..0000000000 --- a/docs/references/projects/create-jwt.md +++ /dev/null @@ -1 +0,0 @@ -Create a new JWT token. This token can be used to authenticate users with custom scopes and expiration time. \ No newline at end of file diff --git a/docs/references/projects/create-smtp-test.md b/docs/references/projects/create-smtp-test.md deleted file mode 100644 index 63cea9d21f..0000000000 --- a/docs/references/projects/create-smtp-test.md +++ /dev/null @@ -1 +0,0 @@ -Send a test email to verify SMTP configuration. \ No newline at end of file diff --git a/docs/references/projects/create.md b/docs/references/projects/create.md deleted file mode 100644 index d502c269ef..0000000000 --- a/docs/references/projects/create.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project. You can create a maximum of 100 projects per account. \ No newline at end of file diff --git a/docs/references/projects/delete-email-template.md b/docs/references/projects/delete-email-template.md deleted file mode 100644 index 332b1d6117..0000000000 --- a/docs/references/projects/delete-email-template.md +++ /dev/null @@ -1 +0,0 @@ -Reset a custom email template to its default value. This endpoint removes any custom content and restores the template to its original state. \ No newline at end of file diff --git a/docs/references/projects/delete.md b/docs/references/projects/delete.md deleted file mode 100644 index 4a8070c082..0000000000 --- a/docs/references/projects/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project by its unique ID. \ No newline at end of file diff --git a/docs/references/projects/get-email-template.md b/docs/references/projects/get-email-template.md deleted file mode 100644 index 6119a0a183..0000000000 --- a/docs/references/projects/get-email-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a custom email template for the specified locale and type. This endpoint returns the template content, subject, and other configuration details. \ No newline at end of file diff --git a/docs/references/projects/get.md b/docs/references/projects/get.md deleted file mode 100644 index b7a1165adc..0000000000 --- a/docs/references/projects/get.md +++ /dev/null @@ -1 +0,0 @@ -Get a project by its unique ID. This endpoint allows you to retrieve the project's details, including its name, description, team, region, and other metadata. \ No newline at end of file diff --git a/docs/references/projects/update-auth-status.md b/docs/references/projects/update-auth-status.md deleted file mode 100644 index 5d39ec29c4..0000000000 --- a/docs/references/projects/update-auth-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific authentication method. Use this endpoint to enable or disable different authentication methods such as email, magic urls or sms in your project. \ No newline at end of file diff --git a/docs/references/projects/update-email-template.md b/docs/references/projects/update-email-template.md deleted file mode 100644 index d2bf124541..0000000000 --- a/docs/references/projects/update-email-template.md +++ /dev/null @@ -1 +0,0 @@ -Update a custom email template for the specified locale and type. Use this endpoint to modify the content of your email templates. \ No newline at end of file diff --git a/docs/references/projects/update-mock-numbers.md b/docs/references/projects/update-mock-numbers.md deleted file mode 100644 index 7fa92455c1..0000000000 --- a/docs/references/projects/update-mock-numbers.md +++ /dev/null @@ -1 +0,0 @@ -Update the list of mock phone numbers for testing. Use these numbers to bypass SMS verification in development. \ No newline at end of file diff --git a/docs/references/projects/update-oauth2.md b/docs/references/projects/update-oauth2.md deleted file mode 100644 index 2285135991..0000000000 --- a/docs/references/projects/update-oauth2.md +++ /dev/null @@ -1 +0,0 @@ -Update the OAuth2 provider configurations. Use this endpoint to set up or update the OAuth2 provider credentials or enable/disable providers. \ No newline at end of file diff --git a/docs/references/projects/update-smtp.md b/docs/references/projects/update-smtp.md deleted file mode 100644 index 7d898e1ed1..0000000000 --- a/docs/references/projects/update-smtp.md +++ /dev/null @@ -1 +0,0 @@ -Update the SMTP configuration for your project. Use this endpoint to configure your project's SMTP provider with your custom settings for sending transactional emails. \ No newline at end of file diff --git a/docs/references/projects/update.md b/docs/references/projects/update.md deleted file mode 100644 index 60c072c477..0000000000 --- a/docs/references/projects/update.md +++ /dev/null @@ -1 +0,0 @@ -Update a project by its unique ID. \ No newline at end of file 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/Auth/Key.php b/src/Appwrite/Auth/Key.php index 0cbaefa4b3..f33f591d52 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -122,9 +122,9 @@ class Key $secret = $key; } - $role = User::ROLE_APPS; + $role = User::ROLE_KEYS; $roles = Config::getParam('roles', []); - $scopes = $roles[User::ROLE_APPS]['scopes'] ?? []; + $scopes = $roles[User::ROLE_KEYS]['scopes'] ?? []; $expired = false; $guestKey = new Key( @@ -270,7 +270,7 @@ class Key $name = $key->getAttribute('name', 'UNKNOWN'); - $role = User::ROLE_APPS; + $role = User::ROLE_KEYS; $scopes = $key->getAttribute('scopes', []); 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..ab98e6df0c 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -342,9 +342,9 @@ class Resolvers $lock->acquire(); - $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')) { @@ -362,10 +362,9 @@ class Resolvers $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); $resolverResponse->setSent(false); - $route = $utopia->match($request, fresh: true); - $request->setRoute($route); + $request->setRoute($utopia->match($request)?->route); - $utopia->execute($route, $request, $resolverResponse); + $utopia->execute($request, $resolverResponse); self::mergeResponseSideEffects($resolverResponse, $response); @@ -384,10 +383,6 @@ class Resolvers $reject($e); return; } finally { - if ($original !== null) { - $utopia->setRoute($original); - } - $lock->release(); unset(self::$locks[\spl_object_hash($utopia)]); } 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 027b948c69..7c69c751c1 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'); @@ -372,6 +375,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 e40879e2a4..f402050391 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -12,6 +12,8 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; use Appwrite\Platform\Modules\Migrations; use Appwrite\Platform\Modules\Notifications; +use Appwrite\Platform\Modules\Organization; +use Appwrite\Platform\Modules\Presences; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -32,6 +34,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 Notifications\Module()); @@ -44,6 +47,7 @@ class Appwrite extends Platform $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); $this->addModule(new Migrations\Module()); + $this->addModule(new Organization\Module()); $this->addModule(new Project\Module()); $this->addModule(new Advisor\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/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index e0464f7e52..9319cffc57 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -93,7 +93,7 @@ class Decrement extends Action public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void { - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index de090f9882..ba74545342 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -93,7 +93,7 @@ class Increment extends Action public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void { - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 2ade0b2b79..2e861dfcce 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -201,7 +201,7 @@ class Create extends Action $documents = [$data]; } - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($isBulk && !$isAPIKey && !$isPrivilegedUser) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index ecc5b152ec..e0863b8ac7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -107,7 +107,7 @@ class Delete extends Action ): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 06f0e9cf1c..c40e70d667 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -78,7 +78,7 @@ class Get extends Action public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, User $user): void { - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index b86d934ffb..4a675615da 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -103,7 +103,7 @@ class Update extends Action $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index fb3d414097..ae940456f0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -108,7 +108,7 @@ class Upsert extends Action throw new Exception($this->getMissingPayloadException()); } - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); 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..afde3d0baa 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; @@ -87,7 +88,7 @@ class XList extends Action public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void { - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -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/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php index f06feccdee..8e085a9481 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php @@ -75,7 +75,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty'); } - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); // API keys and admins can read any transaction, regular users need permissions diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index fe2ad8dbae..de057ae15e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -120,7 +120,7 @@ class Update extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time'); } - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $transaction = ($isAPIKey || $isPrivilegedUser) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 9af5491598..25863d424c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -227,6 +227,7 @@ class Create extends Action } if ($completed) { + $queueForEvents->reset(); return; } @@ -249,6 +250,8 @@ class Create extends Action $metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata); if ($uploaded === $chunks) { + $queueForEvents->reset(); + $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) ->dynamic($deployment, Response::MODEL_DEPLOYMENT); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 24684a3a51..33a3543e4a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -161,7 +161,7 @@ class Create extends Base /* @var Document $function */ $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index 0a9dd01b7e..9908669f84 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -67,7 +67,7 @@ class Get extends Base ) { $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index 6ad2a5ae55..bf31fa0ced 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -77,7 +77,7 @@ class XList extends Base ) { $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 1799e51a12..3ecfd630cb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -95,6 +95,8 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) + ->param('providerBranches', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true) + ->param('providerPaths', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -147,6 +149,8 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + array $providerBranches, + array $providerPaths, string $buildSpecification, string $runtimeSpecification, string $templateRepository, @@ -248,6 +252,8 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerBranches' => $providerBranches, + 'providerPaths' => $providerPaths, 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, ])); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index ee9e930e16..0a06e821fc 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -87,6 +87,8 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) + ->param('providerBranches', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true) + ->param('providerPaths', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -132,6 +134,8 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + ?array $providerBranches, + ?array $providerPaths, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -276,6 +280,8 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerBranches' => $providerBranches ?? $function->getAttribute('providerBranches', []), + 'providerPaths' => $providerPaths ?? $function->getAttribute('providerPaths', []), 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$functionId, $name, $runtime]), diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 5aa95d3bf2..1d4a8dd689 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -104,8 +104,6 @@ class Builds extends Action Executor $executor, array $plan ): void { - Console::log('Build action started'); - $payload = $message->getPayload(); if (empty($payload)) { @@ -113,6 +111,8 @@ class Builds extends Action } $type = $payload['type'] ?? ''; + Span::add('build.type', $type); + $resource = new Document($payload['resource'] ?? []); $deployment = new Document($payload['deployment'] ?? []); $template = new Document($payload['template'] ?? []); @@ -124,7 +124,6 @@ class Builds extends Action switch ($type) { case BUILD_TYPE_DEPLOYMENT: case BUILD_TYPE_RETRY: - Console::info('Creating build for deployment: ' . $deployment->getId()); $github = new GitHub($cache); $this->buildDeployment( $deviceForFunctions, @@ -193,8 +192,6 @@ class Builds extends Action Span::add('deployment.id', $deployment->getId()); Span::add('build.timeout', $timeout); - Console::info('Deployment action started'); - $startTime = DateTime::now(); $durationStart = \microtime(true); @@ -268,7 +265,7 @@ class Builds extends Action $resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')])); } - Console::log('Status marked as processing'); + Span::add('deployment.status', 'processing'); $queueForRealtime ->setPayload($deployment->getArrayCopy()) @@ -359,7 +356,7 @@ class Builds extends Action ->setPayload($deployment->getArrayCopy()) ->trigger(); - Console::log('Template cloned'); + Span::add('build.source_size', $deployment->getAttribute('sourceSize')); } } elseif ($isVcsEnabled) { // VCS and VCS+Temaplte @@ -403,8 +400,6 @@ class Builds extends Action throw new \Exception('Unable to clone code repository: ' . $stderr); } - Console::log('Git repository cloned'); - // Local refactoring for function folder with spaces if (str_contains($rootDirectory, ' ')) { $rootDirectoryWithoutSpaces = str_replace(' ', '', $rootDirectory); @@ -478,8 +473,6 @@ class Builds extends Action $queueForRealtime ->setPayload($deployment->getArrayCopy()) ->trigger(); - - Console::log('Git template pushed'); } $tmpPath = '/tmp/builds/' . $deploymentId; @@ -531,18 +524,17 @@ class Builds extends Action ->setPayload($deployment->getArrayCopy()) ->trigger(); - Console::log('Git source uploaded'); + Span::add('build.source_size', $deployment->getAttribute('sourceSize')); $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } - Console::log('Status marked as building'); - /** Request the executor to build the code... */ $deployment->setAttribute('status', 'building'); $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ 'status' => 'building', ])); + Span::add('deployment.status', 'building'); if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) { $resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')])); @@ -687,11 +679,10 @@ class Builds extends Action } $isCanceled = false; - - Console::log('Runtime creation started'); + $span = Span::current(); Co::join([ - Co\go(function () use ($executor, &$response, $project, $deployment, $source, $resource, $runtime, $vars, $command, $cpus, $memory, $timeout, &$err, $version) { + Co\go(function () use ($executor, &$response, $project, $deployment, $source, $resource, $runtime, $vars, $command, $cpus, $memory, $timeout, &$err, $version, $span) { try { if ($version === 'v2') { $command = 'tar -zxf /tmp/code.tar.gz -C /usr/code && cd /usr/local/src/ && ./build.sh'; @@ -739,16 +730,18 @@ class Builds extends Action outputDirectory: $outputDirectory ?? '' ); - Console::log('createRuntime finished'); } catch (ExecutorTimeout $error) { - Console::warning('createRuntime timed out'); + $span?->set('build.runtime.timed_out', true); + $span?->set('build.runtime.error_type', $error::class); + $span?->set('build.runtime.error_message', $error->getMessage()); $err = new AppwriteException(AppwriteException::BUILD_TIMEOUT, previous: $error); } catch (\Throwable $error) { - Console::warning('createRuntime failed'); + $span?->set('build.runtime.error_type', $error::class); + $span?->set('build.runtime.error_message', $error->getMessage()); $err = $error; } }), - Co\go(function () use ($executor, $project, &$deployment, &$response, $dbForProject, $timeout, &$err, $queueForRealtime, &$isCanceled) { + Co\go(function () use ($executor, $project, &$deployment, &$response, $dbForProject, $timeout, &$err, $queueForRealtime, &$isCanceled, $span) { try { $insideSeparation = false; @@ -756,7 +749,7 @@ class Builds extends Action deploymentId: $deployment->getId(), projectId: $project->getId(), timeout: $timeout, - callback: function ($logs) use (&$response, &$err, $dbForProject, &$isCanceled, &$deployment, $queueForRealtime, &$insideSeparation) { + callback: function ($logs) use (&$response, &$err, $dbForProject, &$isCanceled, &$deployment, $queueForRealtime, &$insideSeparation, $span) { if ($isCanceled) { return; } @@ -767,7 +760,7 @@ class Builds extends Action if ($deployment->getAttribute('status') === 'canceled') { $isCanceled = true; - Console::info('Ignoring realtime logs because build has been canceled'); + $span?->set('build.logs.ignored_reason', 'canceled'); return; } @@ -836,9 +829,10 @@ class Builds extends Action } } ); - Console::warning('listLogs finished'); + $span?->set('build.logs.finished', true); } catch (\Throwable $error) { - Console::warning('listLogs failed'); + $span?->set('build.logs.error_type', $error::class); + $span?->set('build.logs.error_message', $error->getMessage()); if (empty($err)) { $err = $error; } @@ -846,8 +840,6 @@ class Builds extends Action }), ]); - Console::log('Runtime creation finished'); - $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId); if ($latestDeployment->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); @@ -870,6 +862,8 @@ class Builds extends Action $deployment->setAttribute('buildPath', $response['path']); $deployment->setAttribute('buildSize', $response['size']); $deployment->setAttribute('totalSize', $deployment->getAttribute('buildSize', 0) + $deployment->getAttribute('sourceSize', 0)); + Span::add('build.size', $deployment->getAttribute('buildSize')); + Span::add('build.total_size', $deployment->getAttribute('totalSize')); $logs = ''; foreach ($response['output'] as $log) { @@ -908,8 +902,8 @@ class Builds extends Action $deployment->setAttribute('adapter', $detection->getName()); $deployment->setAttribute('fallbackFile', $detection->getFallbackFile() ?? ''); - - Console::log('Adapter detected'); + Span::add('build.adapter', $deployment->getAttribute('adapter')); + Span::add('build.fallback_file', $deployment->getAttribute('fallbackFile')); } elseif ($adapter === 'ssr' && $detection->getName() === 'static') { throw new \Exception('Adapter mismatch. Detected: ' . $detection->getName() . ' does not match with the set adapter: ' . $adapter); } @@ -927,8 +921,6 @@ class Builds extends Action ->setPayload($deployment->getArrayCopy()) ->trigger(); - Console::log('Build details stored'); - $this->afterBuildSuccess($queueForRealtime, $dbForProject, $deployment, $runtime, $adapter); $logs = $deployment->getAttribute('buildLogs', ''); @@ -942,8 +934,7 @@ class Builds extends Action 'buildLogs' => $deployment->getAttribute('buildLogs'), 'status' => 'ready', ])); - - Console::log('Status marked as ready'); + Span::add('deployment.status', 'ready'); if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) { $resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')])); @@ -969,7 +960,7 @@ class Builds extends Action if ($currentActiveStartTime < $deploymentStartTime) { $activateBuild = true; } else { - Console::info('Skipping auto-activation as current deployment is more recent'); + Span::add('build.auto_activation.skipped_reason', 'current_deployment_newer'); } } } else { @@ -1031,7 +1022,7 @@ class Builds extends Action break; } - Console::log('Deployment activated'); + Span::add('build.activated', true); } $this->afterDeploymentSuccess( @@ -1099,7 +1090,7 @@ class Builds extends Action ])); }, $queries); - Console::log('Preview rule created'); + Span::add('build.preview_rule_created', true); } } @@ -1109,6 +1100,7 @@ class Builds extends Action 'buildEndedAt' => $endTime, 'buildDuration' => \intval(\ceil($durationEnd - $durationStart)), ])); + Span::add('build.duration', $deployment->getAttribute('buildDuration')); $queueForRealtime ->setPayload($deployment->getArrayCopy()) ->trigger(); @@ -1119,8 +1111,6 @@ class Builds extends Action return; } - Console::log('Build duration updated'); - /** Update function schedule */ // Inform scheduler if function is still active @@ -1144,23 +1134,21 @@ class Builds extends Action deploymentId: $deployment->getId(), )); - Console::log('Site screenshot queued'); + Span::add('build.screenshot_queued', true); } - - Console::info('Deployment action finished'); } catch (\Throwable $th) { - Console::warning('Build failed:'); - Console::error($th->getMessage()); - Console::error($th->getFile()); - Console::error($th->getLine()); - Console::error($th->getTraceAsString()); - if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); return; } + Span::add('build.error.stage', 'deployment'); + Span::add('build.error.type', $th::class); + Span::add('build.error.message', $th->getMessage()); + Span::add('build.error.file', $th->getFile()); + Span::add('build.error.line', $th->getLine()); + // Color message red $message = $th->getMessage(); if (! \str_contains($message, '')) { @@ -1182,6 +1170,8 @@ class Builds extends Action $deployment->setAttribute('buildEndedAt', $endTime); $deployment->setAttribute('buildDuration', \intval(\ceil($durationEnd - $durationStart))); $deployment->setAttribute('status', 'failed'); + Span::add('deployment.status', 'failed'); + Span::add('build.duration', $deployment->getAttribute('buildDuration')); $deployment->setAttribute('buildLogs', $message); $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ @@ -1200,7 +1190,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); + $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform, true); } } finally { $queueForRealtime @@ -1360,7 +1350,8 @@ class Builds extends Action Database $dbForProject, Database $dbForPlatform, Realtime $queueForRealtime, - array $platform + array $platform, + bool $secondaryError = false ): void { $deployment = new Document(); @@ -1456,9 +1447,14 @@ class Builds extends Action } } } catch (\Throwable $th) { - Console::warning('Git action failed:'); - Console::warning($th->getMessage()); - Console::warning($th->getTraceAsString()); + $span = Span::current(); + $errorPrefix = $secondaryError ? 'build.error.secondary' : 'build.git_action.error'; + $span?->set("{$errorPrefix}.stage", 'git_action'); + $span?->set("{$errorPrefix}.status", $status); + $span?->set("{$errorPrefix}.type", $th::class); + $span?->set("{$errorPrefix}.message", $th->getMessage()); + $span?->set("{$errorPrefix}.file", $th->getFile()); + $span?->set("{$errorPrefix}.line", $th->getLine()); $logs = $deployment->getAttribute('buildLogs', ''); $date = \date('H:i:s'); @@ -1477,7 +1473,7 @@ class Builds extends Action private function cancelDeployment(string $deploymentId, Database $dbForProject, Realtime $queueForRealtime) { - Console::info('Build has been canceled'); + Span::add('deployment.status', 'canceled'); $deployment = $dbForProject->getDocument('deployments', $deploymentId); 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/Notifications/Http/Notifications/Logos/Appwrite/Get.php b/src/Appwrite/Platform/Modules/Notifications/Http/Notifications/Logos/Appwrite/Get.php index de2194bf3d..c9571dd078 100644 --- a/src/Appwrite/Platform/Modules/Notifications/Http/Notifications/Logos/Appwrite/Get.php +++ b/src/Appwrite/Platform/Modules/Notifications/Http/Notifications/Logos/Appwrite/Get.php @@ -179,7 +179,7 @@ class Get extends Action '$sequence' => $alert->getAttribute('resourceInternalId', ''), 'name' => '', 'email' => '', - 'type' => ACTIVITY_TYPE_HIDDEN, + 'type' => ACTOR_TYPE_HIDDEN, ]), resource: 'alert/' . $alert->getId(), mode: APP_MODE_DEFAULT, diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Init.php b/src/Appwrite/Platform/Modules/Organization/Http/Init.php new file mode 100644 index 0000000000..56eb6db3a0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Init.php @@ -0,0 +1,28 @@ +setType(Action::TYPE_INIT) + ->groups(['organization']) + ->inject('team') + ->callback(function (Document $team) { + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + }); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php new file mode 100644 index 0000000000..0160e2aa04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php @@ -0,0 +1,11 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/organization/projects') + ->desc('Create organization project') + ->groups(['api', 'organization']) + ->label('audits.event', 'projects.create') + ->label('audits.resource', 'project/{response.$id}') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'organization', + group: 'projects', + name: 'createProject', + description: <<param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('cache') + ->inject('pools') + ->inject('hooks') + ->inject('team') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $region, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) + { + $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); + + if (!empty($allowList) && !\in_array($region, $allowList)) { + throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); + } + + $auth = Config::getParam('auth', []); + $auths = [ + 'limit' => 0, + 'maxSessions' => 0, + 'passwordHistory' => 0, + 'passwordDictionary' => false, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'personalDataCheck' => false, + 'disposableEmails' => false, + 'canonicalEmails' => false, + 'freeEmails' => false, + 'mockNumbers' => [], + 'sessionAlerts' => false, + 'membershipsUserName' => false, + 'membershipsUserEmail' => false, + 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, + 'invalidateSessions' => true + ]; + + foreach ($auth as $method) { + $auths[$method['key'] ?? ''] = true; + } + + $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; + + if ($projectId === 'console') { + throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); + } + + $databases = Config::getParam('pools-database', []); + + if ($region !== 'default') { + $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + + $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $dsn = $databases[$index]; + } else { + $dsn = $databases[array_rand($databases)]; + } + + // TODO: Temporary until all projects are using shared tables. + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn, $sharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $dsn = $schema . '://' . $dsn . '?database=' . $database; + + if (!empty($namespace)) { + $dsn .= '&namespace=' . $namespace; + } + } + + try { + $project = $dbForPlatform->createDocument('projects', new Document([ + '$id' => $projectId, + '$permissions' => $this->getPermissions($team->getId(), $projectId), + 'name' => $name, + 'teamInternalId' => $team->getSequence(), + 'teamId' => $team->getId(), + 'region' => $region, + 'version' => APP_VERSION_STABLE, + 'services' => new \stdClass(), + 'platforms' => null, + 'oAuthProviders' => [], + 'webhooks' => null, + 'keys' => null, + 'auths' => $auths, + 'accessedAt' => DateTime::now(), + 'search' => implode(' ', [$projectId, $name]), + 'database' => $dsn, + 'labels' => [], + 'status' => PROJECT_STATUS_ACTIVE, + ])); + } catch (Duplicate) { + throw new Exception(Exception::PROJECT_ALREADY_EXISTS); + } + + try { + $dsn = new DSN($dsn); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $dsn); + } + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $projectTables = !\in_array($dsn->getHost(), $sharedTables); + + if ($projectTables) { + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $dbForProject = new Database($adapter, $cache); + $dbForProject + ->setDatabase(APP_DATABASE) + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + + $create = true; + + try { + $dbForProject->create(); + } catch (Duplicate) { + $create = false; + } + + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); + + if ($create) { + /** @var array $collections */ + $collections = Config::getParam('collections', [])['projects'] ?? []; + + foreach ($collections as $key => $collection) { + if (($collection['$collection'] ?? '') !== Database::METADATA) { + continue; + } + + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); + + try { + $dbForProject->createCollection($key, $attributes, $indexes); + } catch (Duplicate) { + // Collection already exists + } + } + } + } + + // Hook allowing instant project mirroring during migration + // Outside of migration, hook is not registered and has no effect + $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php new file mode 100644 index 0000000000..fc8d5cccfc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/organization/projects/:projectId') + ->desc('Delete organization project') + ->groups(['api', 'organization']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.delete') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'organization', + group: 'projects', + name: 'deleteProject', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('publisherForDeletes') + ->inject('authorization') + ->inject('team') + ->callback($this->action(...)); + } + + public function action( + string $projectId, + Response $response, + Database $dbForPlatform, + DeletePublisher $publisherForDeletes, + Authorization $authorization, + Document $team, + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($project->getAttribute('teamInternalId') !== $team->getSequence()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); + } + + $publisherForDeletes->enqueue(new DeleteMessage( + project: $project, + type: DELETE_TYPE_DOCUMENT, + document: $project, + )); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php new file mode 100644 index 0000000000..37f2dd417a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php @@ -0,0 +1,76 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/organization/projects/:projectId') + ->desc('Get organization project') + ->groups(['api', 'organization']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'organization', + group: 'projects', + name: 'getProject', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('team') + ->callback($this->action(...)); + } + + public function action( + string $projectId, + Response $response, + Database $dbForPlatform, + Document $team, + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($project->getAttribute('teamInternalId') !== $team->getSequence()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php new file mode 100644 index 0000000000..c364a5d6df --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -0,0 +1,81 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/organization/projects/:projectId') + ->desc('Update organization project') + ->groups(['api', 'organization']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'organization', + group: 'projects', + name: 'updateProject', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('team') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, Response $response, Database $dbForPlatform, Document $team) + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($project->getAttribute('teamInternalId') !== $team->getSequence()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'name' => $name, + 'search' => implode(' ', [$projectId, $name]), + ])); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php new file mode 100644 index 0000000000..6b45d92175 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -0,0 +1,196 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/organization/projects') + ->desc('List organization projects') + ->groups(['api', 'organization']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'organization', + group: 'projects', + name: 'listProjects', + description: <<param('queries', [], $this->getQueriesValidator(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Projects::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('team') + ->callback($this->action(...)); + } + + public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team) + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + $queries[] = Query::equal('teamInternalId', [$team->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $projectId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('projects', $projectId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Project '{$projectId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + try { + $selectQueries = Query::groupByType($queries)['selections']; + $filterQueries = Query::groupByType($queries)['filters']; + + $projects = $this->find($dbForPlatform, $queries, $selectQueries); + $total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (Order $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->addFilter(new ListSelection($selectQueries, 'projects')); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document([ + 'projects' => $projects, + 'total' => $total, + ]), Response::MODEL_PROJECT_LIST); + } + + // Build mapping of columns to their subQuery filters + private static function getAttributeToSubQueryFilters(): array + { + if (self::$attributeToSubQueryFilters !== null) { + return self::$attributeToSubQueryFilters; + } + + self::$attributeToSubQueryFilters = []; + + $collections = Config::getParam('collections', []); + $projectAttributes = $collections['platform']['projects']['attributes'] ?? []; + + foreach ($projectAttributes as $attribute) { + $attributeId = $attribute['$id'] ?? null; + $filters = $attribute['filters'] ?? []; + + if ($attributeId === null || empty($filters)) { + continue; + } + + // extract only subQuery filters + $subQueryFilters = \array_filter($filters, function ($filter) { + return \str_starts_with($filter, 'subQuery'); + }); + + if (!empty($subQueryFilters)) { + self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters); + } + } + + return self::$attributeToSubQueryFilters; + } + + private function find(Database $dbForPlatform, array $queries, array $selectQueries): array + { + if (empty($selectQueries)) { + return $dbForPlatform->find('projects', $queries); + } + + $selectedAttributes = []; + foreach ($selectQueries as $query) { + foreach ($query->getValues() as $value) { + $selectedAttributes[] = $value; + } + } + + if (\in_array('*', $selectedAttributes)) { + return $dbForPlatform->find('projects', $queries); + } + + $filtersToSkipMap = []; + $selectedAttributesMap = \array_flip($selectedAttributes); + $attributeToSubQueryFilters = self::getAttributeToSubQueryFilters(); + + foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) { + if (!isset($selectedAttributesMap[$attributeName])) { + foreach ($subQueryFilters as $filter) { + $filtersToSkipMap[$filter] = true; + } + } + } + + $filtersToSkip = \array_keys($filtersToSkipMap); + + return empty($filtersToSkip) + ? $dbForPlatform->find('projects', $queries) + : $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Module.php b/src/Appwrite/Platform/Modules/Organization/Module.php new file mode 100644 index 0000000000..eb7a2dc433 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Services/Http.php b/src/Appwrite/Platform/Modules/Organization/Services/Http.php new file mode 100644 index 0000000000..49a8f7d832 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Services/Http.php @@ -0,0 +1,29 @@ +type = Service::TYPE_HTTP; + + // Init hook + $this->addAction(Init::getName(), new Init()); + + // Projects + $this->addAction(CreateProject::getName(), new CreateProject()); + $this->addAction(ListProjects::getName(), new ListProjects()); + $this->addAction(GetProject::getName(), new GetProject()); + $this->addAction(UpdateProject::getName(), new UpdateProject()); + $this->addAction(DeleteProject::getName(), new DeleteProject()); + } +} 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..376359717b --- /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->isKey($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..be658adbd1 --- /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->isKey($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/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index 8c87a41475..fa35b8d6d5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -69,7 +69,7 @@ class Create extends Action ->inject('response') ->inject('project') ->inject('publisherForMails') - ->inject('plan') + ->inject('platform') ->callback($this->action(...)); } @@ -89,7 +89,7 @@ class Create extends Action Response $response, Document $project, MailPublisher $publisherForMails, - array $plan + array $platform, ): void { // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config. // When inline params are provided they are treated as self-contained — project config is ignored @@ -144,14 +144,7 @@ class Create extends Action $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); $template ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); + ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})"); foreach ($emails as $email) { $publisherForMails->enqueue(new MailMessage( @@ -171,6 +164,17 @@ class Create extends Action 'senderEmail' => $senderEmail, 'senderName' => $senderName, ], + variables: [ + 'platform' => $platform['platformName'] ?? APP_NAME, + 'logoUrl' => $platform['logoUrl'] ?? APP_EMAIL_LOGO_URL, + 'accentColor' => $platform['accentColor'] ?? APP_EMAIL_ACCENT_COLOR, + 'twitter' => $platform['twitterUrl'] ?? APP_SOCIAL_TWITTER, + 'discord' => $platform['discordUrl'] ?? APP_SOCIAL_DISCORD, + 'github' => $platform['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE, + 'terms' => $platform['termsUrl'] ?? APP_EMAIL_TERMS_URL, + 'privacy' => $platform['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL, + ], + platform: $platform, )); } 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/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index d2c92fc65c..18250cb140 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -4,9 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\ProjectId; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Request; @@ -54,19 +51,6 @@ class Create extends Action ->label('audits.event', 'projects.create') ->label('audits.resource', 'project/{response.$id}') ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'create', - description: '/docs/references/projects/create.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('teamId', '', new UID(), 'Team unique ID.') diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php index 29c26b33ea..f6df843d07 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -3,9 +3,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Response; use Utopia\Database\Database; @@ -39,19 +36,6 @@ class Update extends Action ->label('scope', 'projects.write') ->label('audits.event', 'projects.update') ->label('audits.resource', 'project/{request.projectId}') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'update', - description: '/docs/references/projects/update.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 0d2a951388..b967c29451 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -4,10 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\ListSelection; @@ -48,22 +44,6 @@ class XList extends Action ->desc('List projects') ->groups(['api', 'projects']) ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'list', - description: <<param('queries', [], $this->getQueriesValidator(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Projects::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index d27755d106..5fd724346c 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -227,6 +227,7 @@ class Create extends Action } if ($completed) { + $queueForEvents->reset(); return; } @@ -257,6 +258,8 @@ class Create extends Action $metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata); if ($uploaded === $chunks) { + $queueForEvents->reset(); + $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) ->dynamic($deployment, Response::MODEL_DEPLOYMENT); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index d01d0d8ca7..9f3d46ffd0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -19,6 +19,7 @@ use Utopia\Database\Helpers\ID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -78,6 +79,8 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) + ->param('providerBranches', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true) + ->param('providerPaths', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -118,6 +121,8 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + array $providerBranches, + array $providerPaths, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -173,6 +178,8 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerBranches' => $providerBranches, + 'providerPaths' => $providerPaths, 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'buildRuntime' => $buildRuntime, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 2aee03265e..bfd8c9f198 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -22,7 +22,9 @@ use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; +use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -81,6 +83,8 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) + ->param('providerBranches', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true) + ->param('providerPaths', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -126,6 +130,8 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + ?array $providerBranches, + ?array $providerPaths, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -271,6 +277,8 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerBranches' => $providerBranches ?? $site->getAttribute('providerBranches', []), + 'providerPaths' => $providerPaths ?? $site->getAttribute('providerPaths', []), 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$siteId, $name, $framework]), diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 8530475f0c..348b19c039 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -115,7 +115,7 @@ class Create extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { @@ -320,6 +320,7 @@ class Create extends Action } if ($completed) { + $queueForEvents->reset(); return; } @@ -337,6 +338,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); } + $queueForEvents->reset(); + $response ->setStatusCode(Response::STATUS_CODE_OK) ->dynamic($file, Response::MODEL_FILE); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index 6d8781d484..c16b374c78 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -84,7 +84,7 @@ class Delete extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php index c876004319..7e5d7d6879 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php @@ -90,7 +90,7 @@ class Get extends Action /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php index c9ce5796eb..d997fe3cc0 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php @@ -65,7 +65,7 @@ class Get extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 68bc2cabae..0cca87c646 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -133,7 +133,7 @@ class Get extends Action $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php index 5b3fd02370..f069b1cc2a 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php @@ -90,7 +90,7 @@ class Get extends Action $disposition = $decoded['disposition'] ?? 'inline'; $dbForProject = $isInternal ? $dbForPlatform : $dbForProject; - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 407f3766df..fb1245d29c 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -81,7 +81,7 @@ class Update extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { @@ -110,7 +110,7 @@ class Update extends Action // Users can only manage their own roles, API keys and Admin users can manage any $roles = $authorization->getRoles(); - if (!$user->isApp($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) { + if (!$user->isKey($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) { foreach (Database::PERMISSIONS as $type) { foreach ($permissions as $permission) { $permission = Permission::parse($permission); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php index b2f00da6d2..b78d582c47 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php @@ -91,7 +91,7 @@ class Get extends Action /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php index 945c4bfd7c..28dfa87c5d 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php @@ -80,7 +80,7 @@ class XList extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5500a56cbc..3bffb091ba 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -103,7 +103,7 @@ class Create extends Action public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, MailPublisher $publisherForMails, MessagingPublisher $publisherForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, array $platform, Password $proofForPassword, Token $proofForToken) { - $isAppUser = $user->isApp($authorization->getRoles()); + $isAppUser = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $invitee = new Document(); $hash = ''; diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index ef8d130855..556f6de52c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -81,7 +81,7 @@ class Get extends Action $roles = $authorization->getRoles(); $isPrivilegedUser = $user->isPrivileged($roles); - $isAppUser = $user->isApp($roles); + $isAppUser = $user->isKey($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php index 540dc8a871..2198531e64 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php @@ -84,7 +84,7 @@ class Update extends Action } $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); - $isAppUser = $user->isApp($authorization->getRoles()); + $isAppUser = $user->isKey($authorization->getRoles()); $isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner'); if ($project->getId() === 'console') { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 7835c8051f..45f7eb33aa 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -134,7 +134,7 @@ class XList extends Action $roles = $authorization->getRoles(); $isPrivilegedUser = $user->isPrivileged($roles); - $isAppUser = $user->isApp($roles); + $isAppUser = $user->isKey($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php index 0d20a58b6b..222b0968be 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php @@ -71,7 +71,7 @@ class Create extends Action public function action(string $teamId, string $name, array $roles, Response $response, User $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents) { $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); - $isAppUser = $user->isApp($authorization->getRoles()); + $isAppUser = $user->isKey($authorization->getRoles()); $teamId = $teamId == 'unique()' ? ID::unique() : $teamId; diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php index 934074d3c2..85247678f8 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php @@ -15,7 +15,7 @@ class Action extends UtopiaAction { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = $user->isApp($authorization->getRoles()); + $isAPIKey = $user->isKey($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php index a40d7fc6b9..993740c61a 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php @@ -130,7 +130,14 @@ class Update extends Action $providerCommitAuthor = $commitDetails["commitAuthor"] ?? ''; $providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? ''; - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); + $prFiles = $github->getPullRequestFiles($owner, $providerRepositoryName, $providerPullRequestId); + $providerAffectedFiles = [ + ...array_column($prFiles, 'filename'), + // Only renamed files include previous_filename; skip missing values from other file changes. + ...array_filter(array_column($prFiles, 'previous_filename')) + ]; + + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index a6f0e7fd6d..27c4eacba3 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -22,6 +22,7 @@ use Utopia\DSN\DSN; use Utopia\Span\Span; use Utopia\System\System; use Utopia\Validator\Contains; +use Utopia\Validator\Globstar; use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; @@ -42,6 +43,7 @@ trait Deployment string $providerCommitMessage, string $providerCommitUrl, string $providerPullRequestId, + array $providerAffectedFiles, bool $external, Database $dbForPlatform, Authorization $authorization, @@ -103,6 +105,32 @@ trait Deployment continue; } + // Skip deployments when the branch or affected files do not match configured build triggers. + $branchTrigger = new Globstar($resource->getAttribute('providerBranches', [])); + if (!$branchTrigger->isValid($providerBranch)) { + Span::add("{$logBase}.build.skipped.reason", 'branch'); + Span::add("{$logBase}.build.skipped", 'true'); + continue; + } + + $providerPaths = $resource->getAttribute('providerPaths', []); + if (!empty($providerPaths) && !empty($providerAffectedFiles)) { + $pathTrigger = new Globstar($providerPaths); + $pathMatched = false; + foreach ($providerAffectedFiles as $file) { + if ($pathTrigger->isValid($file)) { + $pathMatched = true; + break; + } + } + + if (!$pathMatched) { + Span::add("{$logBase}.build.skipped.reason", 'path'); + Span::add("{$logBase}.build.skipped", 'true'); + continue; + } + } + $deploymentId = ID::unique(); $repositoryId = $repository->getId(); $repositoryInternalId = $repository->getSequence(); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php index 0b81504309..c79df05f8a 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -133,7 +133,6 @@ class Create extends Action callable $getProjectDB, array $platform, ) { - $providerBranchCreated = $parsedPayload["branchCreated"] ?? false; $providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false; $providerBranch = $parsedPayload["branch"] ?? ''; $providerBranchUrl = $parsedPayload["branchUrl"] ?? ''; @@ -164,7 +163,8 @@ class Create extends Action // Create new deployment only on push (not committed by us) and not when branch is deleted if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) { - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); + $providerAffectedFiles = $parsedPayload['affectedFiles'] ?? []; + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', $providerAffectedFiles, false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); } } @@ -211,12 +211,19 @@ class Create extends Action $providerCommitAuthor = $commitDetails["commitAuthor"] ?? ''; $providerCommitMessage = $commitDetails["commitMessage"] ?? ''; + $prFiles = $github->getPullRequestFiles($providerRepositoryOwner, $providerRepositoryName, $providerPullRequestId); + $providerAffectedFiles = [ + ...array_column($prFiles, 'filename'), + // Only renamed files include previous_filename; skip missing values from other file changes. + ...array_filter(array_column($prFiles, 'previous_filename')) + ]; + $repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [ Query::equal('providerRepositoryId', [$providerRepositoryId]), Query::orderDesc('$createdAt') ])); - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); } elseif ($action == "closed") { // Allowed external contributions cleanup diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index f6b0345381..09863b8626 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -91,7 +91,7 @@ class Audits extends Action $actorUserEmail = $impersonatorUserId ? $user->getAttribute('impersonatorUserEmail', '') : $user->getAttribute('email', ''); - $userType = $user->getAttribute('type', ACTIVITY_TYPE_USER); + $userType = $user->getAttribute('type', ACTOR_TYPE_USER); // Create event data $eventData = [ @@ -100,7 +100,6 @@ class Audits extends Action 'resource' => $resource, 'userAgent' => $userAgent, 'ip' => $ip, - 'location' => '', 'data' => [ 'userId' => $actorUserId, 'userName' => $actorUserName, diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 78380abc87..f45d990b0d 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: @@ -1032,6 +1038,7 @@ class Deletes extends Action Query::equal('resourceInternalId', [$resourceInternalId]), Query::equal('resourceType', [$resourceType]), Query::orderDesc('$createdAt'), + Query::orderDesc(), Query::offset($executionsRetentionCount), ]); @@ -1752,4 +1759,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..e7c51dec87 --- /dev/null +++ b/src/Appwrite/Presences/State.php @@ -0,0 +1,274 @@ +toString(); + } + } else { + $isAPIKey = $user->isKey($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 08ca654fae..8e29f5f052 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' => [ @@ -769,7 +780,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']; } @@ -870,6 +881,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 07843643c8..bad71ff50c 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'], @@ -732,7 +735,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']; } @@ -768,10 +771,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']; } @@ -839,6 +845,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/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index 211c6449dc..3004efe382 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -17,7 +17,7 @@ class User extends Document public const ROLE_ADMIN = 'admin'; public const ROLE_DEVELOPER = 'developer'; public const ROLE_OWNER = 'owner'; - public const ROLE_APPS = 'apps'; + public const ROLE_KEYS = 'keys'; public const ROLE_SYSTEM = 'system'; public function getEmail(): ?string @@ -39,7 +39,7 @@ class User extends Document { $roles = []; - if (!$this->isApp($authorization->getRoles())) { + if (!$this->isKey($authorization->getRoles())) { if ($this->getId()) { $roles[] = Role::user($this->getId())->toString(); $roles[] = Role::users()->toString(); @@ -115,15 +115,15 @@ class User extends Document } /** - * Is App User? + * Is Key User? * * @param array $roles * * @return bool */ - public function isApp(array $roles): bool + public function isKey(array $roles): bool { - if (in_array(self::ROLE_APPS, $roles)) { + if (in_array(self::ROLE_KEYS, $roles)) { return true; } 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 @@ +getHeader('x-forwarded-user-agent'); if (!empty($forwardedUserAgent)) { $roles = $this->authorization->getRoles(); - $isAppUser = $this->user?->isApp($roles) ?? false; + $isAppUser = $this->user?->isKey($roles) ?? false; if ($isAppUser) { return $forwardedUserAgent; diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 4dcb5674b7..f8fc9cc148 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -39,6 +39,7 @@ class Response extends SwooleResponse public const MODEL_USAGE_TABLE = 'usageTable'; public const MODEL_USAGE_COLLECTION = 'usageCollection'; public const MODEL_USAGE_USERS = 'usageUsers'; + public const MODEL_USAGE_PRESENCE = 'usagePresence'; public const MODEL_USAGE_BUCKETS = 'usageBuckets'; public const MODEL_USAGE_STORAGE = 'usageStorage'; public const MODEL_USAGE_FUNCTIONS = 'usageFunctions'; @@ -64,6 +65,8 @@ class Response extends SwooleResponse public const MODEL_COLUMN_INDEX_LIST = 'columnIndexList'; public const MODEL_DOCUMENT = 'document'; public const MODEL_DOCUMENT_LIST = 'documentList'; + public const MODEL_PRESENCE = 'presence'; + public const MODEL_PRESENCE_LIST = 'presenceList'; public const MODEL_ROW = 'row'; public const MODEL_ROW_LIST = 'rowList'; @@ -592,7 +595,7 @@ class Response extends SwooleResponse $roles = $this->authorization->getRoles(); $user = $this->user ?? new DBUser(); $isPrivilegedUser = $user->isPrivileged($roles); - $isAppUser = $user->isApp($roles); + $isAppUser = $user->isKey($roles); if ((!$isPrivilegedUser && !$isAppUser) && !$this->showSensitive) { $data->setAttribute($key, ''); diff --git a/src/Appwrite/Utopia/Response/Model/Any.php b/src/Appwrite/Utopia/Response/Model/Any.php index 6863748ac8..0ae3040a1d 100644 --- a/src/Appwrite/Utopia/Response/Model/Any.php +++ b/src/Appwrite/Utopia/Response/Model/Any.php @@ -12,6 +12,26 @@ class Any extends Model */ protected bool $any = true; + /** + * JSON wire-format key under which extra/dynamic attributes are exposed in + * generated SDK models (e.g. Document'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/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index 3aea364fe5..fa6040904c 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -182,6 +182,20 @@ class Func extends Model 'default' => false, 'example' => false, ]) + ->addRule('providerBranches', [ + 'type' => self::TYPE_STRING, + 'description' => 'List of branch name patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all branches.', + 'default' => [], + 'example' => ['main', 'feat/*'], + 'array' => true, + ]) + ->addRule('providerPaths', [ + 'type' => self::TYPE_STRING, + 'description' => 'List of file path patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all file changes.', + 'default' => [], + 'example' => ['src/**', '!docs/**'], + 'array' => true, + ]) ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, 'description' => 'Machine specification for deployment builds.', 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/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index af2a21d551..c788833e88 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -94,7 +94,7 @@ class Project extends Model ->addRule('smtpPort', [ 'type' => self::TYPE_INTEGER, 'description' => 'SMTP server port', - 'default' => '', + 'default' => 0, 'example' => 25, ]) ->addRule('smtpUsername', [ @@ -225,7 +225,7 @@ class Project extends Model $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); - $document->setAttribute('smtpPort', $smtp['port'] ?? ''); + $document->setAttribute('smtpPort', (int) ($smtp['port'] ?? 0)); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); diff --git a/src/Appwrite/Utopia/Response/Model/Site.php b/src/Appwrite/Utopia/Response/Model/Site.php index 941b6104df..330dd1c777 100644 --- a/src/Appwrite/Utopia/Response/Model/Site.php +++ b/src/Appwrite/Utopia/Response/Model/Site.php @@ -173,6 +173,20 @@ class Site extends Model 'default' => false, 'example' => false, ]) + ->addRule('providerBranches', [ + 'type' => self::TYPE_STRING, + 'description' => 'List of branch name patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all branches.', + 'default' => [], + 'example' => ['main', 'feat/*'], + 'array' => true, + ]) + ->addRule('providerPaths', [ + 'type' => self::TYPE_STRING, + 'description' => 'List of file path patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all file changes.', + 'default' => [], + 'example' => ['src/**', '!docs/**'], + 'array' => true, + ]) ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, 'description' => 'Machine specification for deployment builds.', 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/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index b1f07c3f9d..44d5d274da 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -2778,17 +2778,17 @@ class FunctionsCustomServerTest extends Scope $this->assertEmpty($executions['body']['executions'][0]['logs']); $this->assertEmpty($executions['body']['executions'][0]['errors']); - // Ensure executions count - $executions = $this->listExecutions($functionId); + $this->assertEventually(function () use ($functionId) { + $executions = $this->listExecutions($functionId); - $this->assertEquals(200, $executions['headers']['status-code']); - $this->assertCount(3, $executions['body']['executions']); + $this->assertEquals(200, $executions['headers']['status-code']); + $this->assertCount(3, $executions['body']['executions']); - // Double check logs and errors are empty - foreach ($executions['body']['executions'] as $execution) { - $this->assertEmpty($execution['logs']); - $this->assertEmpty($execution['errors']); - } + foreach ($executions['body']['executions'] as $execution) { + $this->assertEmpty($execution['logs']); + $this->assertEmpty($execution['errors']); + } + }, 10000, 500); $this->cleanupFunction($functionId); } 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/Organization/ProjectsBase.php b/tests/e2e/Services/Organization/ProjectsBase.php new file mode 100644 index 0000000000..4e18050670 --- /dev/null +++ b/tests/e2e/Services/Organization/ProjectsBase.php @@ -0,0 +1,484 @@ +client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'Organization Test', + ]); + if (\in_array($team['headers']['status-code'], [201, 409])) { + break; + } + \usleep(500000); + } + $this->assertContains($team['headers']['status-code'], [201, 409], 'Setup organization (team) failed'); + + self::$cachedOrganization = [ + 'teamId' => $team['body']['$id'] ?? $teamId, + ]; + + return self::$cachedOrganization; + } + + protected function getOrganizationHeaders(): array + { + $organization = $this->setupOrganization(); + + return array_merge($this->getHeaders(), [ + 'x-appwrite-organization' => $organization['teamId'], + ]); + } + + /** + * Setup and cache a project created via organization endpoints. + */ + protected function setupOrganizationProject(): array + { + if (!empty(self::$cachedProjectData)) { + return self::$cachedProjectData; + } + + $project = null; + for ($i = 0; $i < 3; $i++) { + $project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Organization Project Test', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + if ($project['headers']['status-code'] === 201) { + break; + } + \usleep(500000); + } + $this->assertEquals(201, $project['headers']['status-code'], 'Setup organization project failed'); + + self::$cachedProjectData = [ + 'projectId' => $project['body']['$id'], + 'teamId' => $this->setupOrganization()['teamId'], + ]; + + return self::$cachedProjectData; + } + + public function testCreateProject(): void + { + $organization = $this->setupOrganization(); + $teamId = $organization['teamId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Organization Project Test', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals('Organization Project Test', $response['body']['name']); + $this->assertEquals($teamId, $response['body']['teamId']); + $this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']); + $this->assertArrayHasKey('platforms', $response['body']); + $this->assertArrayHasKey('webhooks', $response['body']); + $this->assertArrayHasKey('keys', $response['body']); + + /** + * Test for FAILURE - missing organization header + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Organization Project Test', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - empty name + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => '', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateDuplicateProject(): void + { + $organization = $this->setupOrganization(); + $projectId = ID::unique(); + + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => $projectId, + 'name' => 'Original Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Test for FAILURE - duplicate project ID + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => $projectId, + 'name' => 'Duplicate Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + $this->assertEquals(409, $response['body']['code']); + $this->assertEquals(Exception::PROJECT_ALREADY_EXISTS, $response['body']['type']); + } + + public function testGetProject(): void + { + $data = $this->setupOrganizationProject(); + $projectId = $data['projectId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($projectId, $response['body']['$id']); + $this->assertEquals('Organization Project Test', $response['body']['name']); + $this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']); + $this->assertArrayHasKey('platforms', $response['body']); + $this->assertArrayHasKey('webhooks', $response['body']); + $this->assertArrayHasKey('keys', $response['body']); + + /** + * Test for FAILURE - project not found + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . ID::unique(), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - project from different organization + */ + $otherTeam = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Other Organization', + ]); + $this->assertContains($otherTeam['headers']['status-code'], [201, 409]); + $otherTeamId = $otherTeam['body']['$id'] ?? $otherTeam['body']['teamId']; + + $otherProject = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], array_merge($this->getHeaders(), [ + 'x-appwrite-organization' => $otherTeamId, + ])), [ + 'projectId' => ID::unique(), + 'name' => 'Other Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $otherProject['headers']['status-code']); + $otherProjectId = $otherProject['body']['$id']; + + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $otherProjectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + public function testUpdateProject(): void + { + $data = $this->setupOrganizationProject(); + $projectId = $data['projectId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'name' => 'Updated Organization Project', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($projectId, $response['body']['$id']); + $this->assertEquals('Updated Organization Project', $response['body']['name']); + + /** + * Test for FAILURE - project not found + */ + $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . ID::unique(), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'name' => 'Should Fail', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - empty name + */ + $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'name' => '', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testDeleteProject(): void + { + $organization = $this->setupOrganization(); + + $project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Project To Delete', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + // Verify project is actually deleted + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - project not found (already deleted) + */ + $response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + public function testListProjects(): void + { + $organization = $this->setupOrganization(); + $teamId = $organization['teamId']; + + // Create a second project in the same organization + $project2 = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Second Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $project2['headers']['status-code']); + $project2Id = $project2['body']['$id']; + + /** + * Test for SUCCESS - basic list + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + $this->assertGreaterThan(0, $response['body']['total']); + + /** + * Test search queries + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders(), [ + 'search' => 'Second Organization Project', + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertIsArray($response['body']['projects']); + $this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']); + + /** + * Test pagination with limit + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['projects']); + + /** + * Test pagination with offset + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::offset(1)->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + + /** + * Test query by name + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::equal('name', ['Second Organization Project'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, count($response['body']['projects'])); + $this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']); + + /** + * Test cursor pagination + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['projects']); + + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + + /** + * Test for FAILURE - invalid cursor + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(), + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testListProjectsQuerySelect(): void + { + $data = $this->setupOrganizationProject(); + $projectId = $data['projectId']; + + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::select(['name'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['projects']); + $this->assertEquals('Organization Project Test', $response['body']['projects'][0]['name']); + } +} diff --git a/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php b/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php new file mode 100644 index 0000000000..5d016eff01 --- /dev/null +++ b/tests/e2e/Services/Organization/ProjectsConsoleClientTest.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]; + } + + /** + * 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/ProjectConsoleClientTest.php b/tests/e2e/Services/Project/ProjectConsoleClientTest.php index a4c8b73efc..3328c7602f 100644 --- a/tests/e2e/Services/Project/ProjectConsoleClientTest.php +++ b/tests/e2e/Services/Project/ProjectConsoleClientTest.php @@ -103,7 +103,7 @@ class ProjectConsoleClientTest extends Scope $this->assertSame('', $response['body']['smtpReplyToEmail']); $this->assertSame('', $response['body']['smtpReplyToName']); $this->assertSame('', $response['body']['smtpHost']); - $this->assertSame('', $response['body']['smtpPort']); + $this->assertSame(0, $response['body']['smtpPort']); $this->assertSame('', $response['body']['smtpUsername']); $this->assertSame('', $response['body']['smtpPassword']); $this->assertSame('', $response['body']['smtpSecure']); 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/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index aa5e6911f1..7b44b0485d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1802,7 +1802,13 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('en-us', $response['body']['locale']); /** Update Email template, fail due to SMTP disabled */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + $projectWithoutSmtp = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Without SMTP', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectWithoutSmtp . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.1', 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/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index bcdb46180f..674fdf09aa 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -22,7 +22,7 @@ class KeyTest extends TestCase 'collections.read', 'documents.read', ]; - $roleScopes = Config::getParam('roles', [])[User::ROLE_APPS]['scopes']; + $roleScopes = Config::getParam('roles', [])[User::ROLE_KEYS]['scopes']; $guestRoleScopes = Config::getParam('roles', [])[User::ROLE_GUESTS]['scopes']; $key = self::generateKey($projectId, $usage, $scopes); @@ -37,7 +37,7 @@ class KeyTest extends TestCase $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Ephemeral Key', $decoded->getName()); @@ -61,7 +61,7 @@ class KeyTest extends TestCase $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Ephemeral Key', $decoded->getName()); $this->assertEquals(['metric123'], $decoded->getDisabledMetrics()); @@ -123,7 +123,7 @@ class KeyTest extends TestCase $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Standard key', $decoded->getName()); @@ -146,7 +146,7 @@ class KeyTest extends TestCase $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Standard key', $decoded->getName()); @@ -194,7 +194,7 @@ class KeyTest extends TestCase $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Standard key', $decoded->getName()); @@ -289,7 +289,7 @@ class KeyTest extends TestCase $this->assertEquals($teamId, $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_ORGANIZATION, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals($scopes, $decoded->getScopes()); $this->assertEquals('Organization key', $decoded->getName()); @@ -336,7 +336,7 @@ class KeyTest extends TestCase $this->assertEquals($teamId, $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_ORGANIZATION, $decoded->getType()); - $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_KEYS, $decoded->getRole()); $this->assertEquals($scopes, $decoded->getScopes()); $this->assertEquals('Organization key', $decoded->getName()); } 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']); + } } diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php index b3638e7d3a..5bd73db132 100644 --- a/tests/unit/Utopia/Database/Documents/UserTest.php +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -179,11 +179,11 @@ class UserTest extends TestCase $this->assertEquals(true, $user->isPrivileged([User::ROLE_ADMIN])); $this->assertEquals(true, $user->isPrivileged([User::ROLE_DEVELOPER])); $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER])); - $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_KEYS])); $this->assertEquals(false, $user->isPrivileged([User::ROLE_SYSTEM])); - $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); - $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_KEYS, User::ROLE_KEYS])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_KEYS, Role::guests()->toString()])); $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); } @@ -192,19 +192,19 @@ class UserTest extends TestCase { $user = new User(); - $this->assertEquals(false, $user->isApp([])); - $this->assertEquals(false, $user->isApp([Role::guests()->toString()])); - $this->assertEquals(false, $user->isApp([Role::users()->toString()])); - $this->assertEquals(false, $user->isApp([User::ROLE_ADMIN])); - $this->assertEquals(false, $user->isApp([User::ROLE_DEVELOPER])); - $this->assertEquals(false, $user->isApp([User::ROLE_OWNER])); - $this->assertEquals(true, $user->isApp([User::ROLE_APPS])); - $this->assertEquals(false, $user->isApp([User::ROLE_SYSTEM])); + $this->assertEquals(false, $user->isKey([])); + $this->assertEquals(false, $user->isKey([Role::guests()->toString()])); + $this->assertEquals(false, $user->isKey([Role::users()->toString()])); + $this->assertEquals(false, $user->isKey([User::ROLE_ADMIN])); + $this->assertEquals(false, $user->isKey([User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isKey([User::ROLE_OWNER])); + $this->assertEquals(true, $user->isKey([User::ROLE_KEYS])); + $this->assertEquals(false, $user->isKey([User::ROLE_SYSTEM])); - $this->assertEquals(true, $user->isApp([User::ROLE_APPS, User::ROLE_APPS])); - $this->assertEquals(true, $user->isApp([User::ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(false, $user->isApp([User::ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(false, $user->isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + $this->assertEquals(true, $user->isKey([User::ROLE_KEYS, User::ROLE_KEYS])); + $this->assertEquals(true, $user->isKey([User::ROLE_KEYS, Role::guests()->toString()])); + $this->assertEquals(false, $user->isKey([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(false, $user->isKey([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); } public function testGuestRoles(): void @@ -327,7 +327,7 @@ class UserTest extends TestCase public function testAppUserRoles(): void { - $this->getAuthorization()->addRole(User::ROLE_APPS); + $this->getAuthorization()->addRole(User::ROLE_KEYS); $user = new User([ '$id' => ID::custom('123'), 'memberships' => [