Merge remote-tracking branch 'origin/1.9.x' into feat-notifications-worker

# Conflicts:
#	app/init/constants.php
#	app/init/resources.php
#	app/init/worker/message.php
#	src/Appwrite/Platform/Appwrite.php
This commit is contained in:
Jake Barnby
2026-05-26 15:22:11 +12:00
174 changed files with 7549 additions and 1226 deletions
+2 -1
View File
@@ -443,7 +443,8 @@ jobs:
Messaging,
Notifications,
Migrations,
Project
Project,
Presences
]
include:
- service: Databases
+2 -61
View File
@@ -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
+186
View File
@@ -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']
]
]
]
];
+1
View File
@@ -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',
+12
View File
@@ -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,
+70 -14
View File
@@ -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',
+5 -1
View File
@@ -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'],
],
+8
View File
@@ -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',
],
];
+49 -49
View File
@@ -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',
+3 -4
View File
@@ -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,
+1 -1
View File
@@ -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);
}
+33 -1
View File
@@ -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,
+10 -33
View File
@@ -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);
}
}
});
+3 -4
View File
@@ -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) : [];
+24 -40
View File
@@ -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',
+4 -5
View File
@@ -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;
+2 -2
View File
@@ -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");
+9 -8
View File
@@ -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';
+6 -1
View File
@@ -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());
+7 -1
View File
@@ -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':
+92 -90
View File
@@ -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());
+2 -2
View File
@@ -596,7 +596,7 @@ return function (Container $context): void {
// These endpoints moved from /v1/projects/:projectId/<resource> to /v1/<resource>
// 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')) {
+1
View File
@@ -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();
}, []);
+299 -315
View File
@@ -1,10 +1,20 @@
<?php
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime as QueueRealtime;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Network\Validator\Origin;
use Appwrite\Presences\State as PresenceState;
use Appwrite\PubSub\Adapter\Pool as PubSubPool;
use Appwrite\Realtime\Message\Dispatcher as MessageDispatcher;
use Appwrite\Realtime\Message\Handlers\Authentication as AuthenticationHandler;
use Appwrite\Realtime\Message\Handlers\Ping as PingHandler;
use Appwrite\Realtime\Message\Handlers\Presence as PresenceHandler;
use Appwrite\Realtime\Message\Handlers\Subscribe as SubscribeHandler;
use Appwrite\Realtime\Message\Handlers\Unsubscribe as UnsubscribeHandler;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -16,9 +26,6 @@ use Swoole\Table;
use Swoole\Timer;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
@@ -28,7 +35,9 @@ use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Authorization as AuthorizationException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
@@ -37,6 +46,8 @@ use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Queue;
use Utopia\Registry\Registry;
use Utopia\Span\Span;
use Utopia\System\System;
@@ -67,6 +78,38 @@ set_exception_handler(function (\Throwable $e) {
));
});
global $container;
if (!$container->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<string, Document> $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<string, true> $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,
+4 -4
View File
@@ -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.*",
Generated
+137 -206
View File
@@ -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": {
+1
View File
@@ -0,0 +1 @@
Delete a presence log by its unique ID.
+1
View File
@@ -0,0 +1 @@
Get presence usage metrics, including the current total of online users and historical online user counts for the selected time range.
+1
View File
@@ -0,0 +1 @@
Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found.
+1
View File
@@ -0,0 +1 @@
List presence logs. Expired entries are filtered out automatically.
+1
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
Create or update a presence log by its user ID.
-1
View File
@@ -1 +0,0 @@
Create a new JWT token. This token can be used to authenticate users with custom scopes and expiration time.
@@ -1 +0,0 @@
Send a test email to verify SMTP configuration.
-1
View File
@@ -1 +0,0 @@
Create a new project. You can create a maximum of 100 projects per account.
@@ -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.
-1
View File
@@ -1 +0,0 @@
Delete a project by its unique ID.
@@ -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.
-1
View File
@@ -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.
@@ -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.
@@ -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.
@@ -1 +0,0 @@
Update the list of mock phone numbers for testing. Use these numbers to bypass SMS verification in development.
@@ -1 +0,0 @@
Update the OAuth2 provider configurations. Use this endpoint to set up or update the OAuth2 provider credentials or enable/disable providers.
-1
View File
@@ -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.
-1
View File
@@ -1 +0,0 @@
Update a project by its unique ID.
+116
View File
@@ -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<AppwriteManager>();
_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: "<PROJECT_ID>",
endpoint: "https://<REGION>.cloud.appwrite.io/v1",
endpointRealtime: "wss://<REGION>.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)
+3 -3
View File
@@ -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', []);
@@ -6,8 +6,13 @@ use Utopia\Database\Document;
final class StatsResources extends Base
{
/**
* @param Document $project
* @param array<int, array{metric: string, value: int}> $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'] ?? [],
);
}
}
+43 -1
View File
@@ -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<int, array{metric: string, value: int}>
*/
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<int, array{metric: string, value: int}> $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<int, array{metric: string, value: int}>
*/
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,
];
}
}
+4
View File
@@ -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';
+3 -8
View File
@@ -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)]);
}
@@ -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`),
+27
View File
@@ -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
*
+4
View File
@@ -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());
}
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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')
@@ -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));
@@ -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));
@@ -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) {
@@ -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)) {
@@ -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));
@@ -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)) {
@@ -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));
@@ -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';
@@ -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
@@ -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)
@@ -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);
@@ -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)) {
@@ -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)) {
@@ -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)) {
@@ -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,
]));
@@ -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]),
@@ -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);
@@ -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),
@@ -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,
@@ -0,0 +1,28 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http;
use Appwrite\Extend\Exception;
use Utopia\Database\Document;
use Utopia\Platform\Action;
class Init extends Action
{
public static function getName(): string
{
return 'init';
}
public function __construct()
{
$this
->setType(Action::TYPE_INIT)
->groups(['organization'])
->inject('team')
->callback(function (Document $team) {
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
});
}
}
@@ -0,0 +1,11 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Platform\Action as AppwriteAction;
use Appwrite\Platform\Permission as AppwritePermission;
class Action extends AppwriteAction
{
use AppwritePermission;
}
@@ -0,0 +1,233 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\ProjectId;
use Appwrite\Utopia\Response;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\DSN\DSN;
use Utopia\Platform\Scope\HTTP;
use Utopia\Pools\Group;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createOrganizationProject';
}
public function __construct()
{
$this
->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: <<<EOT
Create a new project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PROJECT,
)
],
contentType: ContentType::JSON
))
->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);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteOrganizationProject';
}
public function __construct()
{
$this
->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: <<<EOT
Delete a project by its unique ID.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->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();
}
}
@@ -0,0 +1,76 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getOrganizationProject';
}
public function __construct()
{
$this
->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: <<<EOT
Get a project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
],
contentType: ContentType::NONE
))
->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);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateOrganizationProject';
}
public function __construct()
{
$this
->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: <<<EOT
Update a project by its unique ID.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
],
contentType: ContentType::JSON
))
->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);
}
}
@@ -0,0 +1,196 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
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;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
// cached mapping of columns to their subQuery filters
private static ?array $attributeToSubQueryFilters = null;
public static function getName()
{
return 'listOrganizationProjects';
}
protected function getQueriesValidator(): Validator
{
return new Projects();
}
public function __construct()
{
$this
->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: <<<EOT
Get a list of all projects. You can use the query params to filter your results.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT_LIST
)
],
contentType: ContentType::JSON
))
->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);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Organization;
use Appwrite\Platform\Modules\Organization\Services\Http;
use Utopia\Platform\Module as Base;
class Module extends Base
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,29 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Services;
use Appwrite\Platform\Modules\Organization\Http\Init as Init;
use Appwrite\Platform\Modules\Organization\Http\Projects\Create as CreateProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\Delete as DeleteProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\Get as GetProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\Update as UpdateProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\XList as ListProjects;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->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());
}
}
@@ -0,0 +1,88 @@
<?php
namespace Appwrite\Platform\Modules\Presences\HTTP;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action as PlatformAction;
use Appwrite\Presences\State as PresenceState;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Exception\Conflict as ConflictException;
use Utopia\Database\Exception\Restricted as RestrictedException;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
class Delete extends PlatformAction
{
public static function getName()
{
return 'deletePresence';
}
public function __construct()
{
$this
->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();
}
}
@@ -0,0 +1,69 @@
<?php
namespace Appwrite\Platform\Modules\Presences\HTTP;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action as PlatformAction;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends PlatformAction
{
use HTTP;
public static function getName()
{
return 'getPresence';
}
public function __construct()
{
$this
->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);
}
}
@@ -0,0 +1,206 @@
<?php
namespace Appwrite\Platform\Modules\Presences\HTTP;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action as PlatformAction;
use Appwrite\Presences\State as PresenceState;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Parameter;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Conflict as ConflictException;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Validator\Boolean;
use Utopia\Validator\JSON;
use Utopia\Validator\Text;
class Update extends PlatformAction
{
public static function getName()
{
return 'updatePresence';
}
public function __construct()
{
$this
->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);
}
}
@@ -0,0 +1,192 @@
<?php
namespace Appwrite\Platform\Modules\Presences\HTTP;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action as PlatformAction;
use Appwrite\Presences\State as PresenceState;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Parameter;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\JSON;
use Utopia\Validator\Text;
class Upsert extends PlatformAction
{
use HTTP;
public static function getName()
{
return 'upsertPresence';
}
public function __construct()
{
$this
->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);
}
}
@@ -0,0 +1,120 @@
<?php
namespace Appwrite\Platform\Modules\Presences\HTTP\Usage;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action as PlatformAction;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Action;
use Utopia\Validator\WhiteList;
class Get extends PlatformAction
{
public static function getName()
{
return 'getUsage';
}
public function __construct()
{
$this
->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);
}
}
@@ -0,0 +1,178 @@
<?php
namespace Appwrite\Platform\Modules\Presences\HTTP;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action as PlatformAction;
use Appwrite\Presences\State as PresenceState;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Presences as PresencesQueries;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Exception\Relationship as RelationshipException;
use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Range;
class XList extends PlatformAction
{
use HTTP;
public static function getName()
{
return 'listPresences';
}
public function __construct()
{
$this
->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);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Presences;
use Appwrite\Platform\Modules\Presences\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,27 @@
<?php
namespace Appwrite\Platform\Modules\Presences\Services;
use Appwrite\Platform\Modules\Presences\HTTP\Delete as DeletePresence;
use Appwrite\Platform\Modules\Presences\HTTP\Get as GetPresence;
use Appwrite\Platform\Modules\Presences\HTTP\Update as UpdatePresence;
use Appwrite\Platform\Modules\Presences\HTTP\Upsert as UpsertPresence;
use Appwrite\Platform\Modules\Presences\HTTP\Usage\Get as GetUsage;
use Appwrite\Platform\Modules\Presences\HTTP\XList as ListPresences;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->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());
}
}
@@ -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,
));
}
@@ -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();
@@ -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})) {
@@ -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.')
@@ -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)

Some files were not shown because too many files have changed in this diff Show More