mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge branch '1.9.x' into orchestrator-builds-integration
This commit is contained in:
@@ -442,7 +442,8 @@ jobs:
|
||||
VCS,
|
||||
Messaging,
|
||||
Migrations,
|
||||
Project
|
||||
Project,
|
||||
Presences
|
||||
]
|
||||
include:
|
||||
- service: Databases
|
||||
|
||||
+2
-61
@@ -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
|
||||
|
||||
@@ -2754,4 +2754,146 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// Naming it presenceLogs as later it might be only be used as a presence events table only and not for the actual presence
|
||||
'presenceLogs' => [
|
||||
'$collection' => ID::custom(Database::METADATA),
|
||||
'$id' => ID::custom('presenceLogs'),
|
||||
'name' => 'Presence Logs',
|
||||
'attributes' => [
|
||||
[
|
||||
'$id' => ID::custom('userInternalId'),
|
||||
'type' => Database::VAR_ID,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => true,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('userId'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('expiresAt'),
|
||||
'type' => Database::VAR_DATETIME,
|
||||
'format' => '',
|
||||
'size' => 0,
|
||||
'signed' => false,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => ['datetime'],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('status'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('source'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => true,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('hostname'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('metadata'),
|
||||
'type' => Database::VAR_TEXT,
|
||||
'format' => '',
|
||||
'size' => 65535,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => new \stdClass(),
|
||||
'array' => false,
|
||||
'filters' => ['json'],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('permissionsHash'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 32,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
'indexes' => [
|
||||
[
|
||||
'$id' => ID::custom('_unique_userId'),
|
||||
'type' => Database::INDEX_UNIQUE,
|
||||
'attributes' => ['userId'],
|
||||
'lengths' => [Database::LENGTH_KEY],
|
||||
'orders' => [Database::ORDER_ASC]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_userInternal'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['userInternalId'],
|
||||
'orders' => [Database::ORDER_ASC]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_expiresAt'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['expiresAt'],
|
||||
'lengths' => [],
|
||||
'orders' => [Database::ORDER_ASC]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_status'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['status'],
|
||||
'lengths' => [Database::LENGTH_KEY],
|
||||
'orders' => [Database::ORDER_ASC]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_source'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['source'],
|
||||
'lengths' => [Database::LENGTH_KEY],
|
||||
'orders' => [Database::ORDER_ASC]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_source_status'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['source', 'status']
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_permissionsHash'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['permissionsHash']
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -395,6 +395,7 @@ const METRIC_NETWORK_OUTBOUND = 'network.outbound';
|
||||
const METRIC_MAU = 'users.mau';
|
||||
const METRIC_DAU = 'users.dau';
|
||||
const METRIC_WAU = 'users.wau';
|
||||
const METRIC_USERS_PRESENCE = 'users.presence';
|
||||
const METRIC_WEBHOOKS = 'webhooks';
|
||||
const METRIC_PLATFORMS = 'platforms';
|
||||
const METRIC_PROVIDERS = 'providers';
|
||||
|
||||
@@ -174,6 +174,7 @@ use Appwrite\Utopia\Response\Model\PolicySessionInvalidation;
|
||||
use Appwrite\Utopia\Response\Model\PolicySessionLimit;
|
||||
use Appwrite\Utopia\Response\Model\PolicyUserLimit;
|
||||
use Appwrite\Utopia\Response\Model\Preferences;
|
||||
use Appwrite\Utopia\Response\Model\Presence;
|
||||
use Appwrite\Utopia\Response\Model\Project;
|
||||
use Appwrite\Utopia\Response\Model\ProjectAuthMethod;
|
||||
use Appwrite\Utopia\Response\Model\ProjectProtocol;
|
||||
@@ -214,6 +215,7 @@ use Appwrite\Utopia\Response\Model\UsageDocumentsDB;
|
||||
use Appwrite\Utopia\Response\Model\UsageDocumentsDBs;
|
||||
use Appwrite\Utopia\Response\Model\UsageFunction;
|
||||
use Appwrite\Utopia\Response\Model\UsageFunctions;
|
||||
use Appwrite\Utopia\Response\Model\UsagePresence;
|
||||
use Appwrite\Utopia\Response\Model\UsageProject;
|
||||
use Appwrite\Utopia\Response\Model\UsageSite;
|
||||
use Appwrite\Utopia\Response\Model\UsageSites;
|
||||
@@ -237,6 +239,7 @@ Response::setModel(new ErrorDev());
|
||||
// Lists
|
||||
Response::setModel(new BaseList('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW));
|
||||
Response::setModel(new BaseList('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT));
|
||||
Response::setModel(new BaseList('Presences List', Response::MODEL_PRESENCE_LIST, 'presences', Response::MODEL_PRESENCE));
|
||||
Response::setModel(new BaseList('Tables List', Response::MODEL_TABLE_LIST, 'tables', Response::MODEL_TABLE));
|
||||
Response::setModel(new BaseList('Collections List', Response::MODEL_COLLECTION_LIST, 'collections', Response::MODEL_COLLECTION));
|
||||
Response::setModel(new BaseList('Databases List', Response::MODEL_DATABASE_LIST, 'databases', Response::MODEL_DATABASE));
|
||||
@@ -361,6 +364,7 @@ Response::setModel(new Index());
|
||||
Response::setModel(new ColumnIndex());
|
||||
Response::setModel(new Row());
|
||||
Response::setModel(new ModelDocument());
|
||||
Response::setModel(new Presence());
|
||||
Response::setModel(new Log());
|
||||
Response::setModel(new User());
|
||||
Response::setModel(new AlgoMd5());
|
||||
@@ -489,6 +493,7 @@ Response::setModel(new UsageDatabase());
|
||||
Response::setModel(new UsageTable());
|
||||
Response::setModel(new UsageCollection());
|
||||
Response::setModel(new UsageUsers());
|
||||
Response::setModel(new UsagePresence());
|
||||
Response::setModel(new UsageStorage());
|
||||
Response::setModel(new UsageBuckets());
|
||||
Response::setModel(new UsageFunctions());
|
||||
|
||||
@@ -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':
|
||||
|
||||
+87
-85
@@ -53,6 +53,93 @@ global $register;
|
||||
global $container;
|
||||
$container = new Container();
|
||||
|
||||
$container->set('console', fn () => new Document(Config::getParam('console')), []);
|
||||
|
||||
$container->set('executor', fn () => new Executor(), []);
|
||||
|
||||
$container->set('telemetry', fn () => new NoTelemetry(), []);
|
||||
|
||||
$container->set('publisher', fn (Group $pools) => new BrokerPool(publisher: $pools->get('publisher')), ['pools']);
|
||||
|
||||
$container->set('publisherDatabases', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherFunctions', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherMigrations', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherMails', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherDeletes', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherMessaging', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherWebhooks', fn (Publisher $publisher) => $publisher, ['publisher']);
|
||||
|
||||
$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher(
|
||||
$publisherDatabases,
|
||||
new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME))
|
||||
), ['publisherDatabases']);
|
||||
|
||||
$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
$container->set('logger', function ($register) {
|
||||
return $register->get('logger');
|
||||
}, ['register']);
|
||||
@@ -67,83 +154,6 @@ $container->set('localeCodes', function () {
|
||||
return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []));
|
||||
});
|
||||
|
||||
// Queues - shared infrastructure (stateless pool wrappers)
|
||||
$container->set('publisher', function (Group $pools) {
|
||||
return new BrokerPool(publisher: $pools->get('publisher'));
|
||||
}, ['pools']);
|
||||
$container->set('publisherDatabases', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherFunctions', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherMigrations', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherMails', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherDeletes', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherMessaging', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherWebhooks', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
|
||||
), ['publisher']);
|
||||
$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher(
|
||||
$publisherDatabases,
|
||||
new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME))
|
||||
), ['publisherDatabases']);
|
||||
$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
/**
|
||||
* Platform configuration
|
||||
@@ -152,10 +162,6 @@ $container->set('platform', function () {
|
||||
return Config::getParam('platform', []);
|
||||
}, []);
|
||||
|
||||
$container->set('console', function () {
|
||||
return new Document(Config::getParam('console'));
|
||||
}, []);
|
||||
|
||||
$container->set('authorization', function () {
|
||||
return new Authorization();
|
||||
}, []);
|
||||
@@ -214,8 +220,6 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
|
||||
};
|
||||
}, ['pools', 'cache', 'authorization']);
|
||||
|
||||
$container->set('telemetry', fn () => new NoTelemetry());
|
||||
|
||||
$container->set('cache', function (Group $pools, Telemetry $telemetry) {
|
||||
$list = Config::getParam('pools-cache', []);
|
||||
$adapters = [];
|
||||
@@ -416,5 +420,3 @@ $container->set(
|
||||
'isResourceBlocked',
|
||||
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
|
||||
);
|
||||
|
||||
$container->set('executor', fn () => new Executor());
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Publisher\Func as FunctionPublisher;
|
||||
use Appwrite\Event\Realtime;
|
||||
use Appwrite\Event\Webhook;
|
||||
use Appwrite\Usage\Context;
|
||||
@@ -21,7 +20,6 @@ use Utopia\DSN\DSN;
|
||||
use Utopia\Logger\Log;
|
||||
use Utopia\Pools\Group;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Queue\Queue;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
|
||||
use Utopia\System\System;
|
||||
@@ -334,10 +332,6 @@ return function (Container $container): void {
|
||||
return new Webhook($publisher);
|
||||
}, ['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('queueForRealtime', function () {
|
||||
return new Realtime();
|
||||
}, []);
|
||||
|
||||
+298
-314
@@ -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)) {
|
||||
@@ -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,
|
||||
|
||||
+3
-3
@@ -56,18 +56,18 @@
|
||||
"utopia-php/analytics": "0.15.*",
|
||||
"utopia-php/audit": "2.3.*",
|
||||
"utopia-php/auth": "0.5.*",
|
||||
"utopia-php/cache": "^2.1",
|
||||
"utopia-php/cache": "^3.0",
|
||||
"utopia-php/cli": "0.23.*",
|
||||
"utopia-php/compression": "0.1.*",
|
||||
"utopia-php/config": "1.*",
|
||||
"utopia-php/console": "0.1.*",
|
||||
"utopia-php/database": "5.*",
|
||||
"utopia-php/detector": "0.2.*",
|
||||
"utopia-php/domains": "2.*",
|
||||
"utopia-php/domains": "^2.1",
|
||||
"utopia-php/emails": "0.7.*",
|
||||
"utopia-php/dns": "1.7.*",
|
||||
"utopia-php/dsn": "0.2.1",
|
||||
"utopia-php/http": "^2.0@RC",
|
||||
"utopia-php/http": "2.0.0-rc1",
|
||||
"utopia-php/fetch": "^1.1",
|
||||
"utopia-php/validators": "0.2.*",
|
||||
"utopia-php/image": "0.8.*",
|
||||
|
||||
Generated
+113
-182
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "035685d1335039f13e16d0532c874b21",
|
||||
"content-hash": "597066d71be48add0c649828d820a505",
|
||||
"packages": [
|
||||
{
|
||||
"name": "adhocore/jwt",
|
||||
@@ -3615,16 +3615,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/cache",
|
||||
"version": "2.1.0",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/cache.git",
|
||||
"reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e"
|
||||
"reference": "086687d7ae23dd1dae67b943161e8cef143539e1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/cache/zipball/fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e",
|
||||
"reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e",
|
||||
"url": "https://api.github.com/repos/utopia-php/cache/zipball/086687d7ae23dd1dae67b943161e8cef143539e1",
|
||||
"reference": "086687d7ae23dd1dae67b943161e8cef143539e1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3663,9 +3663,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/cache/issues",
|
||||
"source": "https://github.com/utopia-php/cache/tree/2.1.0"
|
||||
"source": "https://github.com/utopia-php/cache/tree/3.0.2"
|
||||
},
|
||||
"time": "2026-05-12T15:03:23+00:00"
|
||||
"time": "2026-05-19T22:38:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/circuit-breaker",
|
||||
@@ -3923,16 +3923,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/database",
|
||||
"version": "5.8.0",
|
||||
"version": "5.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/database.git",
|
||||
"reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696"
|
||||
"reference": "477bae83e27631f78c159f45b0441c0c7dc69050"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/database/zipball/3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696",
|
||||
"reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696",
|
||||
"url": "https://api.github.com/repos/utopia-php/database/zipball/477bae83e27631f78c159f45b0441c0c7dc69050",
|
||||
"reference": "477bae83e27631f78c159f45b0441c0c7dc69050",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3941,7 +3941,7 @@
|
||||
"ext-pdo": "*",
|
||||
"ext-redis": "*",
|
||||
"php": ">=8.4",
|
||||
"utopia-php/cache": "^2.0",
|
||||
"utopia-php/cache": "^3.0",
|
||||
"utopia-php/console": "0.1.*",
|
||||
"utopia-php/mongo": "1.*",
|
||||
"utopia-php/pools": "1.*",
|
||||
@@ -3977,9 +3977,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/database/issues",
|
||||
"source": "https://github.com/utopia-php/database/tree/5.8.0"
|
||||
"source": "https://github.com/utopia-php/database/tree/5.9.0"
|
||||
},
|
||||
"time": "2026-05-12T12:52:44+00:00"
|
||||
"time": "2026-05-17T15:57:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/detector",
|
||||
@@ -4079,16 +4079,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/dns",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/dns.git",
|
||||
"reference": "90bf1bc4a51ceca93590d09e7365317b28d1eb89"
|
||||
"reference": "5225f52a82d4128e69ad17c2a81fcfea6aa00ae1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/dns/zipball/90bf1bc4a51ceca93590d09e7365317b28d1eb89",
|
||||
"reference": "90bf1bc4a51ceca93590d09e7365317b28d1eb89",
|
||||
"url": "https://api.github.com/repos/utopia-php/dns/zipball/5225f52a82d4128e69ad17c2a81fcfea6aa00ae1",
|
||||
"reference": "5225f52a82d4128e69ad17c2a81fcfea6aa00ae1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4099,9 +4099,9 @@
|
||||
"utopia-php/validators": "0.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "1.25.*",
|
||||
"laravel/pint": "1.29.*",
|
||||
"phpstan/phpstan": "2.0.*",
|
||||
"phpunit/phpunit": "12.4.*",
|
||||
"phpunit/phpunit": "12.5.*",
|
||||
"swoole/ide-helper": "5.1.8"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -4130,27 +4130,27 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/dns/issues",
|
||||
"source": "https://github.com/utopia-php/dns/tree/1.7.0"
|
||||
"source": "https://github.com/utopia-php/dns/tree/1.7.2"
|
||||
},
|
||||
"time": "2026-05-13T07:11:31+00:00"
|
||||
"time": "2026-05-20T04:49:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/domains",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/domains.git",
|
||||
"reference": "7f76390998359ef67fcea168f614cbd63a4001e8"
|
||||
"reference": "1b1fea8674e8712e0344d3abb5a7acd558dede50"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/domains/zipball/7f76390998359ef67fcea168f614cbd63a4001e8",
|
||||
"reference": "7f76390998359ef67fcea168f614cbd63a4001e8",
|
||||
"url": "https://api.github.com/repos/utopia-php/domains/zipball/1b1fea8674e8712e0344d3abb5a7acd558dede50",
|
||||
"reference": "1b1fea8674e8712e0344d3abb5a7acd558dede50",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"utopia-php/cache": "^2.0",
|
||||
"php": ">=8.3",
|
||||
"utopia-php/cache": "^3.0",
|
||||
"utopia-php/validators": "0.*"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -4192,9 +4192,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/domains/issues",
|
||||
"source": "https://github.com/utopia-php/domains/tree/2.0.0"
|
||||
"source": "https://github.com/utopia-php/domains/tree/2.1.0"
|
||||
},
|
||||
"time": "2026-05-12T12:52:53+00:00"
|
||||
"time": "2026-05-14T14:33:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/dsn",
|
||||
@@ -4245,16 +4245,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/emails",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/emails.git",
|
||||
"reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107"
|
||||
"reference": "a5f1d111e5023918731f2de96d348f5b6a0de143"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/emails/zipball/115e24aa908e2b1f06c7ff3b94434a0bdbed9107",
|
||||
"reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107",
|
||||
"url": "https://api.github.com/repos/utopia-php/emails/zipball/a5f1d111e5023918731f2de96d348f5b6a0de143",
|
||||
"reference": "a5f1d111e5023918731f2de96d348f5b6a0de143",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4300,9 +4300,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/emails/issues",
|
||||
"source": "https://github.com/utopia-php/emails/tree/0.7.0"
|
||||
"source": "https://github.com/utopia-php/emails/tree/0.7.1"
|
||||
},
|
||||
"time": "2026-05-13T05:01:26+00:00"
|
||||
"time": "2026-05-20T13:05:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/fetch",
|
||||
@@ -4606,16 +4606,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/messaging",
|
||||
"version": "0.22.2",
|
||||
"version": "0.22.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/messaging.git",
|
||||
"reference": "f99feceab575243f3a86ee2e90cd1a6407805def"
|
||||
"reference": "67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/messaging/zipball/f99feceab575243f3a86ee2e90cd1a6407805def",
|
||||
"reference": "f99feceab575243f3a86ee2e90cd1a6407805def",
|
||||
"url": "https://api.github.com/repos/utopia-php/messaging/zipball/67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e",
|
||||
"reference": "67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4651,9 +4651,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/messaging/issues",
|
||||
"source": "https://github.com/utopia-php/messaging/tree/0.22.2"
|
||||
"source": "https://github.com/utopia-php/messaging/tree/0.22.3"
|
||||
},
|
||||
"time": "2026-05-14T08:51:26+00:00"
|
||||
"time": "2026-05-19T05:31:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/migration",
|
||||
@@ -5400,22 +5400,22 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/vcs",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/vcs.git",
|
||||
"reference": "2850dbe975ee69b9466ee6df385fe1679394ce78"
|
||||
"reference": "49d7751f0ae94634b00057177d9823928f6777c6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/2850dbe975ee69b9466ee6df385fe1679394ce78",
|
||||
"reference": "2850dbe975ee69b9466ee6df385fe1679394ce78",
|
||||
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/49d7751f0ae94634b00057177d9823928f6777c6",
|
||||
"reference": "49d7751f0ae94634b00057177d9823928f6777c6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"adhocore/jwt": "^1.1",
|
||||
"php": ">=8.2",
|
||||
"utopia-php/cache": "^2.0",
|
||||
"utopia-php/cache": "^3.0",
|
||||
"utopia-php/fetch": "^1.1"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -5443,9 +5443,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/vcs/issues",
|
||||
"source": "https://github.com/utopia-php/vcs/tree/4.1.0"
|
||||
"source": "https://github.com/utopia-php/vcs/tree/4.2.0"
|
||||
},
|
||||
"time": "2026-05-14T10:04:10+00:00"
|
||||
"time": "2026-05-17T15:58:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/websocket",
|
||||
@@ -5638,16 +5638,16 @@
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "appwrite/sdk-generator",
|
||||
"version": "1.29.5",
|
||||
"version": "1.31.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/appwrite/sdk-generator.git",
|
||||
"reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620"
|
||||
"reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e670edcdfb9ffcec36125b1eb3e4473dce30b620",
|
||||
"reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5699f6da951aef9378fabdcf12f40a9a54fb3128",
|
||||
"reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5656,7 +5656,7 @@
|
||||
"ext-mbstring": "*",
|
||||
"matthiasmullie/minify": "1.3.*",
|
||||
"php": ">=8.3",
|
||||
"twig/twig": "3.14.*"
|
||||
"twig/twig": "3.26.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "7.*",
|
||||
@@ -5683,9 +5683,9 @@
|
||||
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
|
||||
"support": {
|
||||
"issues": "https://github.com/appwrite/sdk-generator/issues",
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/1.29.5"
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/1.31.1"
|
||||
},
|
||||
"time": "2026-05-15T06:49:05+00:00"
|
||||
"time": "2026-05-20T22:22:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brianium/paratest",
|
||||
@@ -6394,11 +6394,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.54",
|
||||
"version": "2.1.55",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566",
|
||||
"reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6443,7 +6443,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-29T13:31:09+00:00"
|
||||
"time": "2026-05-18T11:57:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
@@ -6882,23 +6882,23 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/cli-parser.git",
|
||||
"reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04"
|
||||
"reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04",
|
||||
"reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
|
||||
"reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.0"
|
||||
"phpunit/phpunit": "^12.5.25"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -6927,7 +6927,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
|
||||
"security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0"
|
||||
"source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6947,7 +6947,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-14T09:36:45+00:00"
|
||||
"time": "2026-05-17T05:29:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
@@ -7244,25 +7244,25 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/exporter",
|
||||
"version": "7.0.2",
|
||||
"version": "7.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/exporter.git",
|
||||
"reference": "016951ae10980765e4e7aee491eb288c64e505b7"
|
||||
"reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7",
|
||||
"reference": "016951ae10980765e4e7aee491eb288c64e505b7",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
|
||||
"reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": ">=8.3",
|
||||
"sebastian/recursion-context": "^7.0"
|
||||
"sebastian/recursion-context": "^7.0.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.0"
|
||||
"phpunit/phpunit": "^12.5.25"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -7310,7 +7310,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/exporter/issues",
|
||||
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2"
|
||||
"source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7330,7 +7330,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-24T06:16:11+00:00"
|
||||
"time": "2026-05-20T04:37:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/global-state",
|
||||
@@ -7408,24 +7408,24 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/lines-of-code",
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
|
||||
"reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f"
|
||||
"reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f",
|
||||
"reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
|
||||
"reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"nikic/php-parser": "^5.0",
|
||||
"nikic/php-parser": "^5.7.0",
|
||||
"php": ">=8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.0"
|
||||
"phpunit/phpunit": "^12.5.25"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -7454,15 +7454,27 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
|
||||
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0"
|
||||
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sebastianbergmann",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://liberapay.com/sebastianbergmann",
|
||||
"type": "liberapay"
|
||||
},
|
||||
{
|
||||
"url": "https://thanks.dev/u/gh/sebastianbergmann",
|
||||
"type": "thanks_dev"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-07T04:57:28+00:00"
|
||||
"time": "2026-05-19T16:22:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/object-enumerator",
|
||||
@@ -7656,23 +7668,23 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/type",
|
||||
"version": "6.0.3",
|
||||
"version": "6.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/type.git",
|
||||
"reference": "e549163b9760b8f71f191651d22acf32d56d6d4d"
|
||||
"reference": "82ff822c2edc46724be9f7411d3163021f602773"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d",
|
||||
"reference": "e549163b9760b8f71f191651d22acf32d56d6d4d",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
|
||||
"reference": "82ff822c2edc46724be9f7411d3163021f602773",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.0"
|
||||
"phpunit/phpunit": "^12.5.25"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -7701,7 +7713,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/type/issues",
|
||||
"security": "https://github.com/sebastianbergmann/type/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/type/tree/6.0.3"
|
||||
"source": "https://github.com/sebastianbergmann/type/tree/6.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7721,7 +7733,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-09T06:57:12+00:00"
|
||||
"time": "2026-05-20T06:45:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/version",
|
||||
@@ -8201,86 +8213,6 @@
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php81",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php81.git",
|
||||
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
|
||||
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php81\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.11",
|
||||
@@ -8537,26 +8469,27 @@
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.14.2",
|
||||
"version": "v3.26.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a"
|
||||
"reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a",
|
||||
"reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc",
|
||||
"reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.2",
|
||||
"php": ">=8.1.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "^1.8",
|
||||
"symfony/polyfill-mbstring": "^1.3",
|
||||
"symfony/polyfill-php81": "^1.29"
|
||||
"symfony/polyfill-mbstring": "^1.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-cs-fixer/shim": "^3.0@stable",
|
||||
"phpstan/phpstan": "^2.0@stable",
|
||||
"psr/container": "^1.0|^2.0",
|
||||
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
|
||||
},
|
||||
@@ -8600,7 +8533,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/twigphp/Twig/issues",
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.14.2"
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.26.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -8612,14 +8545,12 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-07T12:36:22+00:00"
|
||||
"time": "2026-05-20T07:31:59+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "dev",
|
||||
"stability-flags": {
|
||||
"utopia-php/http": 5
|
||||
},
|
||||
"stability-flags": {},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Delete a presence log by its unique ID.
|
||||
@@ -0,0 +1 @@
|
||||
Get presence usage metrics, including the current total of online users and historical online user counts for the selected time range.
|
||||
@@ -0,0 +1 @@
|
||||
Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found.
|
||||
@@ -0,0 +1 @@
|
||||
List presence logs. Expired entries are filtered out automatically.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Create or update a presence log by its user ID.
|
||||
@@ -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)
|
||||
@@ -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'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -345,6 +345,7 @@ class Resolvers
|
||||
$original = $utopia->getRoute();
|
||||
try {
|
||||
$request = clone $request;
|
||||
$request->addHeader('x-appwrite-source', 'graphql');
|
||||
|
||||
// Drop json content type so post args are used directly.
|
||||
if (\str_starts_with($request->getHeader('content-type'), 'application/json')) {
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -55,6 +55,9 @@ class V24 extends Migration
|
||||
if ($this->project->getSequence() != 'console') {
|
||||
Console::info('Migrating Databases');
|
||||
$this->migrateDatabases();
|
||||
|
||||
Console::info('Creating presence logs collection');
|
||||
$this->createPresenceLogsCollection();
|
||||
}
|
||||
|
||||
Console::info('Migrating Buckets');
|
||||
@@ -330,6 +333,30 @@ class V24 extends Migration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the presenceLogs collection exists for project databases.
|
||||
*
|
||||
* @return void
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function createPresenceLogsCollection(): void
|
||||
{
|
||||
$collectionId = 'presenceLogs';
|
||||
|
||||
try {
|
||||
Console::info("Ensuring collection \"{$collectionId}\" exists for project \"{$this->project->getId()}\".");
|
||||
$this->dbForProject->purgeCachedCollection($collectionId);
|
||||
$this->dbForProject->purgeCachedDocument(Database::METADATA, $collectionId);
|
||||
|
||||
$this->createCollection($collectionId);
|
||||
} catch (Throwable $th) {
|
||||
Console::warning("Failed to create collection \"{$collectionId}\": {$th->getMessage()}");
|
||||
|
||||
// Re-throw so the migration fails fast and doesn't leave the system in a partially migrated state.
|
||||
throw $th;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate all Bucket tables
|
||||
*
|
||||
|
||||
@@ -11,6 +11,7 @@ use Appwrite\Platform\Modules\Databases;
|
||||
use Appwrite\Platform\Modules\Functions;
|
||||
use Appwrite\Platform\Modules\Health;
|
||||
use Appwrite\Platform\Modules\Migrations;
|
||||
use Appwrite\Platform\Modules\Presences;
|
||||
use Appwrite\Platform\Modules\Project;
|
||||
use Appwrite\Platform\Modules\Projects;
|
||||
use Appwrite\Platform\Modules\Proxy;
|
||||
@@ -31,6 +32,7 @@ class Appwrite extends Platform
|
||||
$this->addModule(new Avatars\Module());
|
||||
$this->addModule(new Databases\Module());
|
||||
$this->addModule(new Projects\Module());
|
||||
$this->addModule(new Presences\Module());
|
||||
$this->addModule(new Functions\Module());
|
||||
$this->addModule(new Health\Module());
|
||||
$this->addModule(new Sites\Module());
|
||||
|
||||
@@ -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')
|
||||
|
||||
+14
-1
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\NotFound as NotFoundException;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Exception\Timeout;
|
||||
@@ -118,7 +119,14 @@ class XList extends Action
|
||||
|
||||
$documentId = $cursor->getValue();
|
||||
|
||||
$cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
|
||||
try {
|
||||
$cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
|
||||
} catch (NotFoundException) {
|
||||
// The collection metadata document exists but the backing store (e.g. a
|
||||
// dedicated DocumentsDB shard) has no table for it. Treat this as a
|
||||
// not-found on the collection so the caller sees a 404 instead of a 500.
|
||||
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
|
||||
}
|
||||
|
||||
if ($cursorDocument->isEmpty()) {
|
||||
$type = ucfirst($this->getContext());
|
||||
@@ -199,6 +207,11 @@ class XList extends Action
|
||||
$documents = $find();
|
||||
$total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
|
||||
}
|
||||
} catch (NotFoundException) {
|
||||
// The collection metadata document exists but the backing store (e.g. a
|
||||
// dedicated DocumentsDB shard) has no table for it. Treat this as a
|
||||
// not-found on the collection so the caller sees a 404 instead of a 500.
|
||||
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
|
||||
} catch (OrderException $e) {
|
||||
$documents = $this->isCollectionsAPI() ? 'documents' : 'rows';
|
||||
$attribute = $this->isCollectionsAPI() ? 'attribute' : 'column';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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->isApp($authorization->getRoles());
|
||||
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
|
||||
|
||||
if ($userId && !$isAPIKey && !$isPrivilegedUser) {
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'userId is not allowed for non-API key and non-privileged users');
|
||||
}
|
||||
|
||||
$presence = $dbForProject->getDocument('presenceLogs', $presenceId);
|
||||
|
||||
if ($presence->isEmpty()) {
|
||||
throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId]);
|
||||
}
|
||||
|
||||
$presenceExpiresAt = $presence->getAttribute('expiresAt');
|
||||
if (!empty($presenceExpiresAt) && DateTime::formatTz($presenceExpiresAt) < DateTime::formatTz(DateTime::now())) {
|
||||
throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId]);
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
|
||||
if ($userId !== null) {
|
||||
$updateData['userId'] = $userId;
|
||||
$userDoc = $dbForProject->getDocument('users', $userId);
|
||||
if ($userDoc->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]);
|
||||
}
|
||||
$updateData['userInternalId'] = $userDoc->getSequence();
|
||||
}
|
||||
|
||||
if ($status !== null) {
|
||||
$updateData['status'] = $status;
|
||||
}
|
||||
|
||||
if ($expiresAt !== null) {
|
||||
$updateData['expiresAt'] = $expiresAt;
|
||||
}
|
||||
|
||||
if ($metadata !== null) {
|
||||
$updateData['metadata'] = $metadata;
|
||||
}
|
||||
|
||||
$updates = new Document($updateData);
|
||||
|
||||
if ($permissions !== null) {
|
||||
$presenceState->setPermissions($updates, $permissions, $user, $authorization);
|
||||
} elseif ($userId !== null && $userId !== $presence->getAttribute('userId')) {
|
||||
$presenceState->setPermissions($updates, null, $user, $authorization, ownerOverride: $userId);
|
||||
}
|
||||
|
||||
if (empty($updateData) && $permissions === null) {
|
||||
if ($purge) {
|
||||
$presenceState->purgeListCache($dbForProject);
|
||||
}
|
||||
$response->dynamic($presence, Response::MODEL_PRESENCE);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$presence = $dbForProject->updateDocument('presenceLogs', $presenceId, $updates);
|
||||
} catch (Duplicate $e) {
|
||||
throw new Exception(Exception::PRESENCE_ALREADY_EXISTS, params: [$presenceId], previous: $e);
|
||||
} catch (StructureException $e) {
|
||||
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e);
|
||||
} catch (ConflictException $e) {
|
||||
throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e);
|
||||
}
|
||||
|
||||
if ($purge) {
|
||||
$presenceState->purgeListCache($dbForProject);
|
||||
}
|
||||
|
||||
$queueForEvents->setParam('presenceId', $presence->getId());
|
||||
|
||||
$response->dynamic($presence, Response::MODEL_PRESENCE);
|
||||
}
|
||||
}
|
||||
@@ -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->isApp($authorization->getRoles());
|
||||
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
|
||||
if ($userId && !$isAPIKey && !$isPrivilegedUser) {
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, "userId is not allowed for non-API key and non-privileged users");
|
||||
}
|
||||
|
||||
if (($isAPIKey || $isPrivilegedUser) && !$userId) {
|
||||
throw new Exception(Exception::GENERAL_BAD_REQUEST, "userId is required for API key and privileged users");
|
||||
}
|
||||
$userInternalId = null;
|
||||
$resolvedUserId = $userId;
|
||||
if (!$isAPIKey && !$isPrivilegedUser) {
|
||||
$userInternalId = $user->getSequence();
|
||||
$resolvedUserId = $user->getId();
|
||||
} else {
|
||||
$fetchedUser = $dbForProject->getDocument('users', $userId);
|
||||
if ($fetchedUser->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]);
|
||||
}
|
||||
|
||||
$userInternalId = (string) $fetchedUser->getSequence();
|
||||
$resolvedUserId = $fetchedUser->getId();
|
||||
}
|
||||
|
||||
if (empty($userInternalId)) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to resolve valid user internal ID.');
|
||||
}
|
||||
$isGraphQL = $request->getHeader('x-appwrite-source') === 'graphql';
|
||||
|
||||
$presenceData = [
|
||||
'userInternalId' => $userInternalId,
|
||||
'userId' => $resolvedUserId,
|
||||
'status' => $status,
|
||||
'source' => $isGraphQL ? 'graphql' : 'rest',
|
||||
'expiresAt' => $expiresAt ?? DateTime::addSeconds(new \DateTime(), 15 * 60),
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
|
||||
$presenceState = new PresenceState();
|
||||
$presenceDocument = new Document($presenceData);
|
||||
$ownerOverride = $permissions === null && ($isAPIKey || $isPrivilegedUser)
|
||||
? $resolvedUserId
|
||||
: null;
|
||||
$presenceState->setPermissions(
|
||||
$presenceDocument,
|
||||
$permissions,
|
||||
$user,
|
||||
$authorization,
|
||||
ownerOverride: $ownerOverride,
|
||||
);
|
||||
$presence = $presenceState->upsertForUser(
|
||||
$dbForProject,
|
||||
$presenceDocument,
|
||||
$presenceId,
|
||||
$userInternalId,
|
||||
fn () => $usage->addMetric(METRIC_USERS_PRESENCE, 1)
|
||||
);
|
||||
$queueForEvents->setParam('presenceId', $presence->getId());
|
||||
|
||||
$response->dynamic($presence, Response::MODEL_PRESENCE);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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})) {
|
||||
|
||||
@@ -6,8 +6,11 @@ use Appwrite\Certificates\Adapter as CertificatesAdapter;
|
||||
use Appwrite\Deletes\Identities;
|
||||
use Appwrite\Deletes\Targets;
|
||||
use Appwrite\Event\Message\Delete as DeleteMessage;
|
||||
use Appwrite\Event\Message\Usage;
|
||||
use Appwrite\Event\Publisher\Delete as DeletePublisher;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Usage\Context as UsageContext;
|
||||
use Executor\Executor;
|
||||
use Throwable;
|
||||
use Utopia\Abuse\Adapters\TimeLimit\Database as AbuseDatabase;
|
||||
@@ -69,6 +72,7 @@ class Deletes extends Action
|
||||
->inject('log')
|
||||
->inject('publisherForDeletes')
|
||||
->inject('getAudit')
|
||||
->inject('publisherForUsage')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
@@ -96,6 +100,7 @@ class Deletes extends Action
|
||||
Log $log,
|
||||
DeletePublisher $publisherForDeletes,
|
||||
callable $getAudit,
|
||||
UsagePublisher $publisherForUsage,
|
||||
): void {
|
||||
$payload = $message->getPayload();
|
||||
|
||||
@@ -216,6 +221,7 @@ class Deletes extends Action
|
||||
$this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime);
|
||||
$this->deleteExpiredSessions($project, $getProjectDB);
|
||||
$this->deleteExpiredTransactions($project, $getProjectDB);
|
||||
$this->deleteExpiredPresences($project, $getProjectDB, $publisherForUsage);
|
||||
$this->deleteOldDeployments($publisherForDeletes, $project, $getProjectDB);
|
||||
break;
|
||||
case DELETE_TYPE_REPORT:
|
||||
@@ -1021,6 +1027,7 @@ class Deletes extends Action
|
||||
Query::equal('resourceInternalId', [$resourceInternalId]),
|
||||
Query::equal('resourceType', [$resourceType]),
|
||||
Query::orderDesc('$createdAt'),
|
||||
Query::orderDesc(),
|
||||
Query::offset($executionsRetentionCount),
|
||||
]);
|
||||
|
||||
@@ -1741,4 +1748,25 @@ class Deletes extends Action
|
||||
// Swallow errors to avoid breaking the cleanup process
|
||||
});
|
||||
}
|
||||
|
||||
private function deleteExpiredPresences(Document $project, callable $getProjectDB, UsagePublisher $publisherForUsage): void
|
||||
{
|
||||
$dbForProject = $getProjectDB($project);
|
||||
|
||||
$now = DateTime::format(new \DateTime());
|
||||
|
||||
$deleted = $dbForProject->deleteDocuments('presenceLogs', [
|
||||
Query::lessThan('expiresAt', $now),
|
||||
], onError: function (Throwable $th) {
|
||||
// Swallow errors to avoid breaking the cleanup process
|
||||
});
|
||||
|
||||
if ($deleted > 0) {
|
||||
$usage = (new UsageContext())->addMetric(METRIC_USERS_PRESENCE, -$deleted);
|
||||
$publisherForUsage->enqueue(new Usage(
|
||||
project: $project,
|
||||
metrics: $usage->getMetrics(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,50 @@ class StatsResources extends Action
|
||||
// Reset documents for each job
|
||||
$this->documents = [];
|
||||
|
||||
if ($statsResources->gauges !== []) {
|
||||
try {
|
||||
$this->writeGauges($getLogsDB, $project, $statsResources->gauges);
|
||||
} catch (Throwable $th) {
|
||||
call_user_func_array($this->logError, [$th, "StatsResources", "write_gauges_{$project->getId()}"]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $getDatabasesDB, $project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch of pre-computed gauge metrics to the logs database for one project.
|
||||
*
|
||||
* Builds (1h, 1d, inf) period documents for each metric using the existing
|
||||
* createStatsDocuments helper, then commits via writeDocuments — same code path the
|
||||
* standard counting flow uses, just with externally-supplied values.
|
||||
*
|
||||
* @param callable $getLogsDB
|
||||
* @param Document $project
|
||||
* @param array<int, array{metric: string, value: int}> $gauges
|
||||
*/
|
||||
protected function writeGauges(callable $getLogsDB, Document $project, array $gauges): void
|
||||
{
|
||||
$region = $project->getAttribute('region', '');
|
||||
|
||||
foreach ($gauges as $gauge) {
|
||||
if ($gauge['metric'] === '') {
|
||||
continue;
|
||||
}
|
||||
$this->createStatsDocuments($region, $gauge['metric'], $gauge['value']);
|
||||
}
|
||||
|
||||
if ($this->documents === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var \Utopia\Database\Database $dbForLogs */
|
||||
$dbForLogs = call_user_func($getLogsDB, $project);
|
||||
$this->writeDocuments($dbForLogs, $project);
|
||||
}
|
||||
|
||||
protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, callable $getDatabasesDB, Document $project): void
|
||||
{
|
||||
/** @var \Utopia\Database\Database $dbForLogs */
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Presences;
|
||||
|
||||
use Appwrite\Event\Event as QueueEvent;
|
||||
use Appwrite\Event\Message\Usage as UsageMessage;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Event\Realtime as QueueRealtime;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Usage\Context as UsageContext;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Throwable;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Conflict as ConflictException;
|
||||
use Utopia\Database\Exception\Duplicate as DuplicateException;
|
||||
use Utopia\Database\Exception\NotFound as NotFoundException;
|
||||
use Utopia\Database\Exception\Structure as StructureException;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
|
||||
class State
|
||||
{
|
||||
public const LIST_CACHE_FIELD_PRESENCES = 'presences';
|
||||
public const LIST_CACHE_FIELD_TOTAL = 'total';
|
||||
public const COLLECTION_ID = 'presenceLogs';
|
||||
|
||||
public function setPermissions(
|
||||
Document $document,
|
||||
?array $permissions,
|
||||
User $user,
|
||||
Authorization $authorization,
|
||||
?string $ownerOverride = null,
|
||||
): Document {
|
||||
$allowedPermissions = [
|
||||
Database::PERMISSION_READ,
|
||||
Database::PERMISSION_UPDATE,
|
||||
Database::PERMISSION_DELETE,
|
||||
Database::PERMISSION_WRITE,
|
||||
];
|
||||
|
||||
if ($ownerOverride !== null) {
|
||||
$permissions = [];
|
||||
foreach ($allowedPermissions as $permission) {
|
||||
$permissions[] = (new Permission($permission, 'user', $ownerOverride))->toString();
|
||||
}
|
||||
} else {
|
||||
$isAPIKey = $user->isApp($authorization->getRoles());
|
||||
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
|
||||
|
||||
$permissions = Permission::aggregate($permissions, $allowedPermissions);
|
||||
|
||||
if (\is_null($permissions)) {
|
||||
$permissions = [];
|
||||
if (!empty($user->getId()) && !$isPrivilegedUser) {
|
||||
foreach ($allowedPermissions as $permission) {
|
||||
$permissions[] = (new Permission($permission, 'user', $user->getId()))->toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAPIKey && !$isPrivilegedUser) {
|
||||
$this->checkPermissions($permissions, $authorization);
|
||||
}
|
||||
}
|
||||
|
||||
sort($permissions, SORT_STRING);
|
||||
$document->setAttribute('$permissions', $permissions);
|
||||
$document->setAttribute('permissionsHash', \md5(\json_encode($permissions)));
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
public function upsertForUser(
|
||||
Database $dbForProject,
|
||||
Document $presenceDocument,
|
||||
string $presenceId,
|
||||
mixed $userInternalId,
|
||||
?callable $onPresenceCreated = null
|
||||
): Document {
|
||||
if ($presenceId === 'unique()') {
|
||||
$presenceId = ID::unique();
|
||||
}
|
||||
$presenceDocument->setAttribute('$id', $presenceId);
|
||||
|
||||
$presenceCreated = false;
|
||||
|
||||
try {
|
||||
if ($dbForProject->getAdapter()->getSupportForUpsertOnUniqueIndex()) {
|
||||
$existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userInternalId', [$userInternalId])]);
|
||||
if ($existingPresence->isEmpty()) {
|
||||
$presenceCreated = true;
|
||||
} else {
|
||||
$presenceDocument->setAttribute('$id', $existingPresence->getId());
|
||||
}
|
||||
$presence = $dbForProject->upsertDocument(self::COLLECTION_ID, $presenceDocument);
|
||||
} else {
|
||||
$presence = $dbForProject->withTransaction(function () use ($dbForProject, $presenceDocument, $userInternalId, &$presenceCreated) {
|
||||
$existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userInternalId', [$userInternalId])]);
|
||||
|
||||
if ($existingPresence->isEmpty()) {
|
||||
$presenceCreated = true;
|
||||
return $dbForProject->createDocument(self::COLLECTION_ID, $presenceDocument);
|
||||
}
|
||||
|
||||
$currentPresence = $dbForProject->getDocument(self::COLLECTION_ID, $existingPresence->getId(), forUpdate: true);
|
||||
|
||||
if ($currentPresence->isEmpty()) {
|
||||
throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$existingPresence->getId()]);
|
||||
}
|
||||
|
||||
$presenceDocument->setAttribute('$id', $currentPresence->getId());
|
||||
|
||||
return $dbForProject->updateDocument(self::COLLECTION_ID, $currentPresence->getId(), $presenceDocument);
|
||||
});
|
||||
}
|
||||
|
||||
if ($presenceCreated && $onPresenceCreated !== null) {
|
||||
call_user_func($onPresenceCreated);
|
||||
}
|
||||
|
||||
return $presence;
|
||||
} catch (DuplicateException $e) {
|
||||
throw new Exception(Exception::PRESENCE_ALREADY_EXISTS, params: [$presenceId], previous: $e);
|
||||
} catch (NotFoundException $e) {
|
||||
throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId], previous: $e);
|
||||
} catch (StructureException $e) {
|
||||
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e);
|
||||
} catch (ConflictException $e) {
|
||||
throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkPermissions(array $permissions, Authorization $authorization): void
|
||||
{
|
||||
foreach (Database::PERMISSIONS as $type) {
|
||||
foreach ($permissions as $permission) {
|
||||
$permission = Permission::parse($permission);
|
||||
if ($permission->getPermission() != $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$role = (new Role(
|
||||
$permission->getRole(),
|
||||
$permission->getIdentifier(),
|
||||
$permission->getDimension()
|
||||
))->toString();
|
||||
|
||||
if (!$authorization->hasRole($role)) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $authorization->getRoles()) . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getListCacheFieldKey(array $roles, array $queries, string $type): string
|
||||
{
|
||||
$serialized = \array_map(
|
||||
static fn ($query) => $query instanceof Query ? $query->toArray() : $query,
|
||||
$queries,
|
||||
);
|
||||
|
||||
return \sprintf(
|
||||
'%s:%s:%s',
|
||||
\md5(\json_encode($roles)),
|
||||
\md5(\json_encode($serialized)),
|
||||
$type,
|
||||
);
|
||||
}
|
||||
|
||||
public function getListCacheField(
|
||||
Database $dbForProject,
|
||||
array $roles,
|
||||
array $queries,
|
||||
string $type,
|
||||
int $ttl
|
||||
): mixed {
|
||||
$cacheField = $this->getListCacheFieldKey($roles, $queries, $type);
|
||||
[$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID);
|
||||
|
||||
try {
|
||||
return $dbForProject->getCache()->load($collectionKey, $ttl, $cacheField);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function setListCacheField(
|
||||
Database $dbForProject,
|
||||
array $roles,
|
||||
array $queries,
|
||||
string $type,
|
||||
mixed $value
|
||||
): void {
|
||||
$cacheField = $this->getListCacheFieldKey($roles, $queries, $type);
|
||||
[$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID);
|
||||
|
||||
try {
|
||||
$dbForProject->getCache()->save($collectionKey, $value, $cacheField);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public function purgeListCache(Database $dbForProject): bool
|
||||
{
|
||||
[$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID);
|
||||
|
||||
return $dbForProject->getCache()->purge($collectionKey);
|
||||
}
|
||||
|
||||
public function triggerUsage(
|
||||
UsagePublisher $publisher,
|
||||
Document $project,
|
||||
int $value,
|
||||
): void {
|
||||
if ($project->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$usage = new UsageContext();
|
||||
$usage->addMetric(METRIC_USERS_PRESENCE, $value);
|
||||
|
||||
$publisher->enqueue(new UsageMessage(
|
||||
project: $project,
|
||||
metrics: $usage->getMetrics(),
|
||||
));
|
||||
} catch (Throwable $th) {
|
||||
if (\function_exists('logError')) {
|
||||
\logError($th, 'realtimeStats', tags: ['projectId' => $project->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function triggerEvent(
|
||||
QueueEvent $queueForEvents,
|
||||
QueueRealtime $queueForRealtime,
|
||||
Document $project,
|
||||
User $user,
|
||||
string $eventName,
|
||||
Document $presence,
|
||||
): void {
|
||||
if ($project->isEmpty() || $presence->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$queueForEvents
|
||||
->reset()
|
||||
->setProject($project)
|
||||
->setUser($user)
|
||||
->setEvent($eventName)
|
||||
->setParam('presenceId', $presence->getId())
|
||||
->setPayload($presence->getArrayCopy());
|
||||
|
||||
$queueForRealtime
|
||||
->reset()
|
||||
->setProject($project)
|
||||
->setUser($user)
|
||||
->from($queueForEvents)
|
||||
->trigger();
|
||||
} catch (Throwable $th) {
|
||||
if (\function_exists('logError')) {
|
||||
\logError($th, 'realtimePresenceEvent', tags: [
|
||||
'projectId' => $project->getId(),
|
||||
'event' => $eventName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Utopia\DI\Container;
|
||||
use Utopia\Platform\Action;
|
||||
|
||||
class Dispatcher
|
||||
{
|
||||
public const LABEL_MESSAGE_TYPE = 'messageType';
|
||||
public const LABEL_PAYLOAD_SHAPE = 'payloadShape';
|
||||
public const LABEL_REQUIRES_PROJECT = 'requiresProjectContext';
|
||||
|
||||
public const PAYLOAD_SHAPE_OBJECT = 'object';
|
||||
public const PAYLOAD_SHAPE_LIST = 'list';
|
||||
|
||||
private const REQUIRED_PARAM_ERROR_FORMAT = 'Payload is not valid. %s is required';
|
||||
|
||||
/**
|
||||
* @var array<string, Action>
|
||||
*/
|
||||
private array $handlers = [];
|
||||
|
||||
public function addHandler(Action $handler): self
|
||||
{
|
||||
$labels = $handler->getLabels();
|
||||
$type = $labels[self::LABEL_MESSAGE_TYPE]
|
||||
?? throw new \LogicException('Realtime message handler is missing the messageType label.');
|
||||
|
||||
$this->handlers[$type] = $handler;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Action>
|
||||
*/
|
||||
public function getHandlers(): array
|
||||
{
|
||||
return $this->handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a parsed websocket message to the handler that registered for its `type`,
|
||||
* runs param validation + dependency injection, and returns whatever the handler returns.
|
||||
* Errors propagate so the caller can render them as websocket error frames.
|
||||
*
|
||||
* @param Container $container per-message container resolving 'connection', 'project',
|
||||
* 'projectId' and any handler-declared injections.
|
||||
* @param array<mixed> $message decoded inbound websocket frame: `['type' => ..., 'data' => ...]`.
|
||||
* @return array<string, mixed>|null the handler's response payload (already shaped for the
|
||||
* wire), or null when the handler chooses not to reply.
|
||||
*/
|
||||
public function dispatch(Container $container, array $message): ?array
|
||||
{
|
||||
$type = $message['type'] ?? '';
|
||||
if (!\is_string($type) || !isset($this->handlers[$type])) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
|
||||
}
|
||||
|
||||
$handler = $this->handlers[$type];
|
||||
$labels = $handler->getLabels();
|
||||
|
||||
$requiresProject = $labels[self::LABEL_REQUIRES_PROJECT] ?? true;
|
||||
if ($requiresProject && empty($container->get('projectId'))) {
|
||||
throw new Exception(
|
||||
Exception::REALTIME_POLICY_VIOLATION,
|
||||
'Missing project context. Reconnect to the project first.'
|
||||
);
|
||||
}
|
||||
|
||||
$shape = $labels[self::LABEL_PAYLOAD_SHAPE] ?? self::PAYLOAD_SHAPE_OBJECT;
|
||||
$dataPresent = \array_key_exists('data', $message);
|
||||
$data = $dataPresent ? $message['data'] : null;
|
||||
|
||||
$args = $this->resolveArgs($handler, $data, $shape, $container);
|
||||
|
||||
return ($handler->getCallback())(...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ordered argument list for the handler callback by walking the action's
|
||||
* declared option sequence. Params come from the inbound `data` (for object shape) or
|
||||
* the entire data value (for list shape). Injections come from the per-message container.
|
||||
*
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function resolveArgs(
|
||||
Action $handler,
|
||||
mixed $data,
|
||||
string $shape,
|
||||
Container $container,
|
||||
): array {
|
||||
$values = [];
|
||||
$dataPresent = $data !== null;
|
||||
foreach ($handler->getParams() as $key => $param) {
|
||||
if ($shape === self::PAYLOAD_SHAPE_LIST) {
|
||||
// The whole `data` field is the value of this single param. `present` reflects
|
||||
// whether the inbound message actually contained the `data` key.
|
||||
$present = $dataPresent;
|
||||
$value = $dataPresent ? $data : $param['default'];
|
||||
} else {
|
||||
$present = \is_array($data) && \array_key_exists($key, $data);
|
||||
$value = $present ? $data[$key] : $param['default'];
|
||||
}
|
||||
|
||||
if (!$present && !$param['optional']) {
|
||||
throw new Exception(
|
||||
Exception::REALTIME_MESSAGE_FORMAT_INVALID,
|
||||
\sprintf(self::REQUIRED_PARAM_ERROR_FORMAT, \ucfirst($key)),
|
||||
);
|
||||
}
|
||||
|
||||
if ($present && !($param['skipValidation'] ?? false)) {
|
||||
$validator = $param['validator'];
|
||||
if (\is_callable($validator) && !($validator instanceof \Utopia\Validator)) {
|
||||
$validator = $validator();
|
||||
}
|
||||
if (!$validator->isValid($value)) {
|
||||
throw new Exception(
|
||||
Exception::REALTIME_MESSAGE_FORMAT_INVALID,
|
||||
\sprintf('%s: %s', $key, $validator->getDescription())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$values[$key] = $value;
|
||||
}
|
||||
|
||||
$ordered = [];
|
||||
foreach ($handler->getOptions() as $optionKey => $option) {
|
||||
if (($option['type'] ?? '') === 'param') {
|
||||
$name = \substr($optionKey, \strlen('param:'));
|
||||
$ordered[] = $values[$name] ?? null;
|
||||
} else {
|
||||
$ordered[] = $container->get($option['name']);
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Handlers;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Messaging\Adapter\Realtime;
|
||||
use Appwrite\Realtime\Message\Dispatcher;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Auth\Hashes\Sha;
|
||||
use Utopia\Auth\Proofs\Token;
|
||||
use Utopia\Auth\Store;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Span\Span;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Authentication extends Action
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Authenticate the connection with a session token')
|
||||
->label(Dispatcher::LABEL_MESSAGE_TYPE, 'authentication')
|
||||
->param('session', '', new Text(2048), 'Encoded session token')
|
||||
->inject('connectionId')
|
||||
->inject('realtime')
|
||||
->inject('database')
|
||||
->inject('register')
|
||||
->inject('response')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function action(
|
||||
string $session,
|
||||
int $connectionId,
|
||||
Realtime $realtime,
|
||||
Database $database,
|
||||
Registry $register,
|
||||
Response $response,
|
||||
): array {
|
||||
$store = new Store();
|
||||
$store->decode($session);
|
||||
|
||||
$userId = $store->getProperty('id', '');
|
||||
if ($userId !== '') {
|
||||
$database->purgeCachedDocument('users', $userId);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $database->getDocument('users', $userId);
|
||||
|
||||
// TODO: move proof construction to the DI container so there's one source of truth.
|
||||
$proofForToken = new Token();
|
||||
$proofForToken->setHash(new Sha());
|
||||
|
||||
if (
|
||||
empty($user->getId())
|
||||
|| !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
|
||||
) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.');
|
||||
}
|
||||
|
||||
$roles = $user->getRoles($database->getAuthorization());
|
||||
|
||||
$authorization = $realtime->connections[$connectionId]['authorization'] ?? null;
|
||||
$projectId = $realtime->connections[$connectionId]['projectId'] ?? null;
|
||||
// Capture the pre-auth userId before unsubscribe() clears the connection entry,
|
||||
// so we can rebind any account channels that were stored under it.
|
||||
$previousUserId = $realtime->connections[$connectionId]['userId'] ?? '';
|
||||
|
||||
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId));
|
||||
$meta = $realtime->getSubscriptionMetadata($connectionId);
|
||||
|
||||
$realtime->unsubscribe($connectionId);
|
||||
|
||||
if (!empty($projectId)) {
|
||||
foreach ($meta as $subscriptionId => $subscription) {
|
||||
$queries = Query::parseQueries($subscription['queries'] ?? []);
|
||||
$channels = Realtime::rebindAccountChannels(
|
||||
$subscription['channels'] ?? [],
|
||||
$previousUserId,
|
||||
$user->getId(),
|
||||
);
|
||||
|
||||
$realtime->subscribe(
|
||||
$projectId,
|
||||
$connectionId,
|
||||
$subscriptionId,
|
||||
$roles,
|
||||
$channels,
|
||||
$queries,
|
||||
$user->getId(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($authorization !== null) {
|
||||
$realtime->connections[$connectionId]['authorization'] = $authorization;
|
||||
}
|
||||
|
||||
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId));
|
||||
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
|
||||
if ($subscriptionDelta !== 0) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')
|
||||
->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
|
||||
Span::add('realtime.subscription_delta', $subscriptionDelta);
|
||||
|
||||
return [
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
'to' => 'authentication',
|
||||
'success' => true,
|
||||
'user' => $response->output($user, Response::MODEL_ACCOUNT),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Handlers;
|
||||
|
||||
use Appwrite\Realtime\Message\Dispatcher;
|
||||
use Utopia\Platform\Action;
|
||||
|
||||
class Ping extends Action
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Reply to client heartbeat')
|
||||
->label(Dispatcher::LABEL_MESSAGE_TYPE, 'ping')
|
||||
->label(Dispatcher::LABEL_REQUIRES_PROJECT, false)
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function action(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'pong',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Handlers;
|
||||
|
||||
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\Messaging\Adapter\Realtime;
|
||||
use Appwrite\Presences\State as PresenceState;
|
||||
use Appwrite\Realtime\Message\Dispatcher;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Permissions;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\JSON;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Presence extends Action
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Upsert a presence document for the authenticated user')
|
||||
->label(Dispatcher::LABEL_MESSAGE_TYPE, 'presence')
|
||||
->param('status', '', new Text(2048), 'Presence status')
|
||||
->param('presenceId', 'unique()', new Text(36), 'Presence document ID', true)
|
||||
->param('metadata', null, new JSON(), 'Optional metadata payload', true, [], true)
|
||||
->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
|
||||
->inject('connectionId')
|
||||
->inject('realtime')
|
||||
->inject('database')
|
||||
->inject('authorization')
|
||||
->inject('presenceState')
|
||||
->inject('project')
|
||||
->inject('publisherForUsage')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForRealtime')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|null $permissions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function action(
|
||||
string $status,
|
||||
string $presenceId,
|
||||
mixed $metadata,
|
||||
?array $permissions,
|
||||
int $connectionId,
|
||||
Realtime $realtime,
|
||||
Database $database,
|
||||
Authorization $authorization,
|
||||
PresenceState $presenceState,
|
||||
?Document $project,
|
||||
UsagePublisher $publisherForUsage,
|
||||
QueueEvent $queueForEvents,
|
||||
QueueRealtime $queueForRealtime,
|
||||
): array {
|
||||
if ($project === null || $project->isEmpty()) {
|
||||
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Presence requires a project context.');
|
||||
}
|
||||
|
||||
$userId = $realtime->connections[$connectionId]['userId'] ?? '';
|
||||
if (empty($userId)) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED, 'User must be authorized');
|
||||
}
|
||||
|
||||
$user = new User($database->getDocument('users', $userId)->getArrayCopy());
|
||||
if ($user->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]);
|
||||
}
|
||||
|
||||
$presenceData = [
|
||||
'userInternalId' => $user->getSequence(),
|
||||
'userId' => $user->getId(),
|
||||
'source' => 'realtime',
|
||||
'status' => $status,
|
||||
'expiresAt' => DateTime::format((new \DateTime())->modify('+30 days')),
|
||||
'hostname' => \gethostname() ?: null,
|
||||
];
|
||||
if ($metadata !== null) {
|
||||
$presenceData['metadata'] = $metadata;
|
||||
}
|
||||
|
||||
$presenceDocument = new Document($presenceData);
|
||||
$presenceState->setPermissions($presenceDocument, $permissions, $user, $authorization);
|
||||
|
||||
$presence = $presenceState->upsertForUser(
|
||||
$database,
|
||||
$presenceDocument,
|
||||
$presenceId,
|
||||
(string) $user->getSequence(),
|
||||
function () use ($presenceState, $publisherForUsage, $project): void {
|
||||
$presenceState->triggerUsage($publisherForUsage, $project, 1);
|
||||
},
|
||||
);
|
||||
|
||||
$presence->removeAttribute('$collection');
|
||||
$presence->removeAttribute('$tenant');
|
||||
$presence->removeAttribute('hostname');
|
||||
$presence->removeAttribute('permissionsHash');
|
||||
$presence->removeAttribute('userInternalId');
|
||||
|
||||
$realtime->connections[$connectionId]['presences'][$presence->getId()] = $presence;
|
||||
|
||||
$presenceState->triggerEvent(
|
||||
$queueForEvents,
|
||||
$queueForRealtime,
|
||||
$project,
|
||||
$user,
|
||||
'presences.[presenceId].upsert',
|
||||
$presence,
|
||||
);
|
||||
|
||||
return [
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
'to' => 'presence',
|
||||
'presence' => $presence->getArrayCopy(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Handlers;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Messaging\Adapter\Realtime;
|
||||
use Appwrite\Realtime\Message\Dispatcher;
|
||||
use Appwrite\Realtime\Message\Validators\SubscribePayload as SubscribePayloadValidator;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Span\Span;
|
||||
|
||||
class Subscribe extends Action
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Bulk subscribe to realtime channels')
|
||||
->label(Dispatcher::LABEL_MESSAGE_TYPE, 'subscribe')
|
||||
->label(Dispatcher::LABEL_PAYLOAD_SHAPE, Dispatcher::PAYLOAD_SHAPE_LIST)
|
||||
->param('items', null, fn () => new SubscribePayloadValidator(), 'Subscriptions to add')
|
||||
->inject('connectionId')
|
||||
->inject('realtime')
|
||||
->inject('register')
|
||||
->inject('projectId')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{channels: array<int, string>, queries?: array<int, string>, subscriptionId?: string}> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function action(
|
||||
array $items,
|
||||
int $connectionId,
|
||||
Realtime $realtime,
|
||||
Registry $register,
|
||||
?string $projectId,
|
||||
): array {
|
||||
$roles = $realtime->connections[$connectionId]['roles'] ?? [Role::guests()->toString()];
|
||||
$userId = $realtime->connections[$connectionId]['userId'] ?? '';
|
||||
|
||||
$parsedPayloads = [];
|
||||
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId));
|
||||
|
||||
foreach ($items as $payload) {
|
||||
$subscriptionId = \array_key_exists('subscriptionId', $payload)
|
||||
? $payload['subscriptionId']
|
||||
: ID::unique();
|
||||
|
||||
$queries = $payload['queries'] ?? [];
|
||||
|
||||
try {
|
||||
$convertedQueries = Realtime::convertQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId));
|
||||
|
||||
$parsedPayloads[] = [
|
||||
'subscriptionId' => $subscriptionId,
|
||||
'channels' => $payload['channels'],
|
||||
'convertedChannels' => $convertedChannels,
|
||||
'queries' => $convertedQueries,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($parsedPayloads as $parsedPayload) {
|
||||
$realtime->subscribe(
|
||||
$projectId,
|
||||
$connectionId,
|
||||
$parsedPayload['subscriptionId'],
|
||||
$roles,
|
||||
$parsedPayload['convertedChannels'],
|
||||
$parsedPayload['queries'],
|
||||
);
|
||||
}
|
||||
|
||||
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId));
|
||||
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
|
||||
$subscriptionsRequested = \count($parsedPayloads);
|
||||
|
||||
if ($subscriptionDelta !== 0) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')
|
||||
->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
|
||||
Span::add('realtime.subscription_delta', $subscriptionDelta);
|
||||
Span::add('realtime.subscriptions_requested', $subscriptionsRequested);
|
||||
Span::add('realtime.subscribe.subscriptions_count', $subscriptionsRequested);
|
||||
|
||||
return [
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
'to' => 'subscribe',
|
||||
'success' => true,
|
||||
'subscriptions' => \array_map(static fn (array $parsed): array => [
|
||||
'subscriptionId' => $parsed['subscriptionId'],
|
||||
'channels' => $parsed['convertedChannels'],
|
||||
'queries' => \array_map(static fn ($q) => $q->toString(), $parsed['queries']),
|
||||
], $parsedPayloads),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Handlers;
|
||||
|
||||
use Appwrite\Messaging\Adapter\Realtime;
|
||||
use Appwrite\Realtime\Message\Dispatcher;
|
||||
use Appwrite\Realtime\Message\Validators\UnsubscribePayload as UnsubscribePayloadValidator;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Span\Span;
|
||||
|
||||
class Unsubscribe extends Action
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Bulk remove subscriptions by id')
|
||||
->label(Dispatcher::LABEL_MESSAGE_TYPE, 'unsubscribe')
|
||||
->label(Dispatcher::LABEL_PAYLOAD_SHAPE, Dispatcher::PAYLOAD_SHAPE_LIST)
|
||||
->param('items', null, fn () => new UnsubscribePayloadValidator(), 'Subscriptions to remove')
|
||||
->inject('connectionId')
|
||||
->inject('realtime')
|
||||
->inject('register')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{subscriptionId: string}> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function action(
|
||||
array $items,
|
||||
int $connectionId,
|
||||
Realtime $realtime,
|
||||
Registry $register,
|
||||
): array {
|
||||
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId));
|
||||
|
||||
$unsubscribeResults = [];
|
||||
foreach ($items as $payload) {
|
||||
$subscriptionId = $payload['subscriptionId'];
|
||||
$unsubscribeResults[] = [
|
||||
'subscriptionId' => $subscriptionId,
|
||||
'removed' => $realtime->unsubscribeSubscription($connectionId, $subscriptionId),
|
||||
];
|
||||
}
|
||||
|
||||
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId));
|
||||
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
|
||||
$subscriptionsRequested = \count($items);
|
||||
$subscriptionsRemoved = \count(\array_filter(
|
||||
$unsubscribeResults,
|
||||
static fn (array $item): bool => $item['removed']
|
||||
));
|
||||
|
||||
if ($subscriptionDelta !== 0) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')
|
||||
->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
|
||||
Span::add('realtime.subscription_delta', $subscriptionDelta);
|
||||
Span::add('realtime.subscriptions_requested', $subscriptionsRequested);
|
||||
Span::add('realtime.subscriptions_removed', $subscriptionsRemoved);
|
||||
|
||||
return [
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
'to' => 'unsubscribe',
|
||||
'success' => true,
|
||||
'subscriptions' => $unsubscribeResults,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Validators;
|
||||
|
||||
use Appwrite\Utopia\Database\Validator\CustomId;
|
||||
use Utopia\Validator;
|
||||
|
||||
class SubscribePayload extends Validator
|
||||
{
|
||||
protected string $description = 'Payload is not valid.';
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function isArray(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return self::TYPE_ARRAY;
|
||||
}
|
||||
|
||||
public function isValid(mixed $value): bool
|
||||
{
|
||||
if (!\is_array($value) || !\array_is_list($value)) {
|
||||
$this->description = 'Payload is not valid.';
|
||||
return false;
|
||||
}
|
||||
|
||||
$customId = new CustomId();
|
||||
|
||||
foreach ($value as $payload) {
|
||||
if (!\is_array($payload)) {
|
||||
$this->description = 'Each subscribe payload must be an object.';
|
||||
return false;
|
||||
}
|
||||
if (\array_key_exists('subscriptionId', $payload) && !$customId->isValid($payload['subscriptionId'])) {
|
||||
$this->description = 'subscriptionId is not a valid id.';
|
||||
return false;
|
||||
}
|
||||
if (!\array_key_exists('channels', $payload)) {
|
||||
$this->description = 'channels is not present in payload.';
|
||||
return false;
|
||||
}
|
||||
if (!\is_array($payload['channels']) || !\array_is_list($payload['channels'])) {
|
||||
$this->description = 'channels is not a valid array.';
|
||||
return false;
|
||||
}
|
||||
foreach ($payload['channels'] as $channel) {
|
||||
if (!\is_string($channel)) {
|
||||
$this->description = 'channels must contain only strings.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (\array_key_exists('queries', $payload)
|
||||
&& (!\is_array($payload['queries']) || !\array_is_list($payload['queries']))
|
||||
) {
|
||||
$this->description = 'queries is not a valid array.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Realtime\Message\Validators;
|
||||
|
||||
use Utopia\Validator;
|
||||
|
||||
class UnsubscribePayload extends Validator
|
||||
{
|
||||
protected string $description = 'Payload is not valid.';
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function isArray(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return self::TYPE_ARRAY;
|
||||
}
|
||||
|
||||
public function isValid(mixed $value): bool
|
||||
{
|
||||
if (!\is_array($value) || !\array_is_list($value)) {
|
||||
$this->description = 'Payload is not valid.';
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($value as $payload) {
|
||||
if (
|
||||
!\is_array($payload)
|
||||
|| !\array_key_exists('subscriptionId', $payload)
|
||||
|| !\is_string($payload['subscriptionId'])
|
||||
|| $payload['subscriptionId'] === ''
|
||||
) {
|
||||
$this->description = 'Each unsubscribe payload must include a non-empty subscriptionId.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ abstract class Format
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'endpoint' => 'https://localhost',
|
||||
'endpoint.docs' => 'https://<REGION>.cloud.appwrite.io/v1',
|
||||
'version' => '1.0.0',
|
||||
'terms' => '',
|
||||
'support.email' => '',
|
||||
@@ -923,6 +924,16 @@ abstract class Format
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'presences':
|
||||
switch ($method) {
|
||||
case 'getUsage':
|
||||
switch ($param) {
|
||||
case 'range':
|
||||
return 'UsageRange';
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1022,6 +1033,19 @@ abstract class Format
|
||||
return $values;
|
||||
}
|
||||
|
||||
protected function shouldEmitDefaultForSchema(mixed $default, array $schema): bool
|
||||
{
|
||||
if (isset($schema['enum'])) {
|
||||
return \in_array($default, $schema['enum'], true);
|
||||
}
|
||||
|
||||
if (isset($schema['items']['enum'])) {
|
||||
return \is_array($default) && empty(\array_diff($default, $schema['items']['enum']));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array
|
||||
{
|
||||
$config = [
|
||||
|
||||
@@ -55,11 +55,22 @@ class OpenAPI3 extends Format
|
||||
'servers' => [
|
||||
[
|
||||
'url' => $this->getParam('endpoint', ''),
|
||||
'description' => 'Appwrite Cloud endpoint.',
|
||||
],
|
||||
[
|
||||
'url' => $this->getParam('endpoint.docs', ''),
|
||||
'url' => \str_replace('<REGION>', '{region}', $this->getParam('endpoint.docs', '')),
|
||||
'description' => 'Appwrite Cloud regional endpoint. Replace `{region}` with your project region.',
|
||||
'variables' => [
|
||||
'region' => [
|
||||
'default' => 'fra',
|
||||
'description' => 'Appwrite Cloud region.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'x-appwrite' => [
|
||||
'endpointDocs' => $this->getParam('endpoint.docs', ''),
|
||||
],
|
||||
'paths' => [],
|
||||
'tags' => $this->services,
|
||||
'components' => [
|
||||
@@ -768,7 +779,7 @@ class OpenAPI3 extends Format
|
||||
break;
|
||||
}
|
||||
|
||||
if ($parameter['emitDefault']) { // Param has default value
|
||||
if ($parameter['emitDefault'] && $this->shouldEmitDefaultForSchema($param['default'], $node['schema'])) { // Param has default value
|
||||
$node['schema']['default'] = $param['default'];
|
||||
}
|
||||
|
||||
@@ -869,6 +880,13 @@ class OpenAPI3 extends Format
|
||||
|
||||
if ($model->isAny()) {
|
||||
$output['components']['schemas'][$model->getType()]['additionalProperties'] = true;
|
||||
|
||||
$additionalKey = \method_exists($model, 'getAdditionalPropertiesKey')
|
||||
? $model->getAdditionalPropertiesKey()
|
||||
: null;
|
||||
if ($additionalKey !== null) {
|
||||
$output['components']['schemas'][$model->getType()]['x-additional-properties-key'] = $additionalKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($required)) {
|
||||
|
||||
@@ -55,6 +55,9 @@ class Swagger2 extends Format
|
||||
],
|
||||
'host' => \parse_url($this->getParam('endpoint', ''), PHP_URL_HOST),
|
||||
'x-host-docs' => \parse_url($this->getParam('endpoint.docs', ''), PHP_URL_HOST),
|
||||
'x-appwrite' => [
|
||||
'endpointDocs' => $this->getParam('endpoint.docs', ''),
|
||||
],
|
||||
'basePath' => \parse_url($this->getParam('endpoint', ''), PHP_URL_PATH),
|
||||
'schemes' => [\parse_url($this->getParam('endpoint', ''), PHP_URL_SCHEME)],
|
||||
'consumes' => ['application/json', 'multipart/form-data'],
|
||||
@@ -731,7 +734,7 @@ class Swagger2 extends Format
|
||||
break;
|
||||
}
|
||||
|
||||
if ($parameter['emitDefault']) { // Param has default value
|
||||
if ($parameter['emitDefault'] && $this->shouldEmitDefaultForSchema($param['default'], $node)) { // Param has default value
|
||||
$node['default'] = $param['default'];
|
||||
}
|
||||
|
||||
@@ -767,10 +770,13 @@ class Swagger2 extends Format
|
||||
$body['schema']['properties'][$name] = [
|
||||
'type' => $node['type'],
|
||||
'description' => $node['description'],
|
||||
'default' => $node['default'] ?? null,
|
||||
'x-example' => $node['x-example'] ?? null,
|
||||
];
|
||||
|
||||
if (\array_key_exists('default', $node)) {
|
||||
$body['schema']['properties'][$name]['default'] = $node['default'];
|
||||
}
|
||||
|
||||
if (isset($node['format'])) {
|
||||
$body['schema']['properties'][$name]['format'] = $node['format'];
|
||||
}
|
||||
@@ -838,6 +844,13 @@ class Swagger2 extends Format
|
||||
|
||||
if ($model->isAny()) {
|
||||
$output['definitions'][$model->getType()]['additionalProperties'] = true;
|
||||
|
||||
$additionalKey = \method_exists($model, 'getAdditionalPropertiesKey')
|
||||
? $model->getAdditionalPropertiesKey()
|
||||
: null;
|
||||
if ($additionalKey !== null) {
|
||||
$output['definitions'][$model->getType()]['x-additional-properties-key'] = $additionalKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($required)) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Database\Validator\Queries;
|
||||
|
||||
class Presences extends Base
|
||||
{
|
||||
public const ALLOWED_ATTRIBUTES = [
|
||||
'userInternalId',
|
||||
'userId',
|
||||
'expiresAt',
|
||||
'status',
|
||||
'source',
|
||||
];
|
||||
|
||||
/**
|
||||
* Expression constructor
|
||||
*
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('presenceLogs', self::ALLOWED_ATTRIBUTES);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class Response extends SwooleResponse
|
||||
public const MODEL_USAGE_TABLE = 'usageTable';
|
||||
public const MODEL_USAGE_COLLECTION = 'usageCollection';
|
||||
public const MODEL_USAGE_USERS = 'usageUsers';
|
||||
public const MODEL_USAGE_PRESENCE = 'usagePresence';
|
||||
public const MODEL_USAGE_BUCKETS = 'usageBuckets';
|
||||
public const MODEL_USAGE_STORAGE = 'usageStorage';
|
||||
public const MODEL_USAGE_FUNCTIONS = 'usageFunctions';
|
||||
@@ -64,6 +65,8 @@ class Response extends SwooleResponse
|
||||
public const MODEL_COLUMN_INDEX_LIST = 'columnIndexList';
|
||||
public const MODEL_DOCUMENT = 'document';
|
||||
public const MODEL_DOCUMENT_LIST = 'documentList';
|
||||
public const MODEL_PRESENCE = 'presence';
|
||||
public const MODEL_PRESENCE_LIST = 'presenceList';
|
||||
public const MODEL_ROW = 'row';
|
||||
public const MODEL_ROW_LIST = 'rowList';
|
||||
|
||||
|
||||
@@ -12,6 +12,26 @@ class Any extends Model
|
||||
*/
|
||||
protected bool $any = true;
|
||||
|
||||
/**
|
||||
* JSON wire-format key under which extra/dynamic attributes are exposed in
|
||||
* generated SDK models (e.g. Document<T>'s `data` slot). Default null means
|
||||
* SDK templates fall back to their hardcoded "data" key. Set this on
|
||||
* subclasses (via setAdditionalPropertiesKey) to use a custom key like
|
||||
* "metadata" while still benefiting from the generic `Model<T>` mapping.
|
||||
*/
|
||||
protected ?string $additionalPropertiesKey = null;
|
||||
|
||||
public function setAdditionalPropertiesKey(string $key): self
|
||||
{
|
||||
$this->additionalPropertiesKey = $key;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAdditionalPropertiesKey(): ?string
|
||||
{
|
||||
return $this->additionalPropertiesKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Name
|
||||
*
|
||||
|
||||
@@ -67,6 +67,12 @@ class File extends Model
|
||||
'default' => 0,
|
||||
'example' => 17890,
|
||||
])
|
||||
->addRule('sizeActual', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'File actual stored size in bytes after compression and/or encryption.',
|
||||
'default' => 0,
|
||||
'example' => 12345,
|
||||
])
|
||||
->addRule('chunksTotal', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Total number of chunks available',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Document as DatabaseDocument;
|
||||
|
||||
class Presence extends Any
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Presence';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_PRESENCE;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Expose user-defined extras under "metadata" instead of the SDK default "data"
|
||||
$this->setAdditionalPropertiesKey('metadata');
|
||||
|
||||
$this
|
||||
->addRule('$id', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'Presence ID.',
|
||||
'default' => '',
|
||||
'example' => '5e5ea5c16897e',
|
||||
])
|
||||
->addRule('$createdAt', [
|
||||
'type' => self::TYPE_DATETIME,
|
||||
'description' => 'Presence creation date in ISO 8601 format.',
|
||||
'default' => '',
|
||||
'example' => self::TYPE_DATETIME_EXAMPLE,
|
||||
])
|
||||
->addRule('$updatedAt', [
|
||||
'type' => self::TYPE_DATETIME,
|
||||
'description' => 'Presence update date in ISO 8601 format.',
|
||||
'default' => '',
|
||||
'example' => self::TYPE_DATETIME_EXAMPLE,
|
||||
])
|
||||
->addRule('$permissions', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'Presence permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).',
|
||||
'default' => '',
|
||||
'example' => ['read("any")'],
|
||||
'array' => true,
|
||||
])
|
||||
->addRule('userId', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'User ID.',
|
||||
'default' => '',
|
||||
'example' => '674af8f3e12a5f9ac0be',
|
||||
])
|
||||
->addRule('status', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'Presence status.',
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'example' => 'online',
|
||||
])
|
||||
->addRule('source', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'Presence source.',
|
||||
'default' => '',
|
||||
'example' => 'HTTP',
|
||||
])
|
||||
->addRule('expiresAt', [
|
||||
'type' => self::TYPE_DATETIME,
|
||||
'description' => 'Presence expiry date in ISO 8601 format.',
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'example' => self::TYPE_DATETIME_EXAMPLE,
|
||||
]);
|
||||
// User-defined extras flow through Any's generic mapping, surfaced under
|
||||
// the "metadata" key declared via setAdditionalPropertiesKey() above.
|
||||
}
|
||||
|
||||
public function filter(DatabaseDocument $document): DatabaseDocument
|
||||
{
|
||||
$document->removeAttribute('$collection');
|
||||
$document->removeAttribute('$tenant');
|
||||
$document->removeAttribute('hostname');
|
||||
$document->removeAttribute('permissionsHash');
|
||||
$document->removeAttribute('userInternalId');
|
||||
|
||||
foreach ($document->getAttributes() as $attribute) {
|
||||
if (\is_array($attribute)) {
|
||||
foreach ($attribute as $subAttribute) {
|
||||
if ($subAttribute instanceof DatabaseDocument) {
|
||||
$this->filter($subAttribute);
|
||||
}
|
||||
}
|
||||
} elseif ($attribute instanceof DatabaseDocument) {
|
||||
$this->filter($attribute);
|
||||
}
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
use Appwrite\Utopia\Response\Model;
|
||||
|
||||
class UsagePresence extends Model
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->addRule('range', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'Time range of the usage stats.',
|
||||
'default' => '',
|
||||
'example' => '30d',
|
||||
])
|
||||
->addRule('usersOnlineTotal', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Current total number of online users.',
|
||||
'default' => 0,
|
||||
'example' => 0,
|
||||
])
|
||||
->addRule('presences', [
|
||||
'type' => Response::MODEL_METRIC,
|
||||
'description' => 'Aggregated number of online users per period.',
|
||||
'default' => [],
|
||||
'example' => [],
|
||||
'array' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'UsagePresence';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_USAGE_PRESENCE;
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -37,14 +37,19 @@ class Bus
|
||||
|
||||
foreach ($listeners as $listener) {
|
||||
$deps = array_map($resolver, $listener->getInjections());
|
||||
Span::init('listener.' . $listener::getName());
|
||||
Span::add('bus.event', $event::class);
|
||||
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.event', $event::class);
|
||||
|
||||
try {
|
||||
($listener->getCallback())($event, ...$deps);
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.success', true);
|
||||
} catch (\Throwable $e) {
|
||||
Span::error($e);
|
||||
} finally {
|
||||
Span::current()?->finish();
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.success', false);
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.error.code', $e->getCode());
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.error.message', $e->getMessage());
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.error.line', $e->getLine());
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.error.file', $e->getFile());
|
||||
Span::current()?->add('listener.' . $listener::getName() . '.error.trace', $e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ class HTTPTest extends Scope
|
||||
$body = $response['body'];
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertIsString($body['server']);
|
||||
$this->assertIsString($body['client-web']);
|
||||
$this->assertIsString($body['server-web']);
|
||||
$this->assertIsString($body['client-flutter']);
|
||||
$this->assertIsString($body['console-web']);
|
||||
$this->assertIsString($body['server-nodejs']);
|
||||
|
||||
@@ -18,6 +18,7 @@ use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Datetime as DatetimeValidator;
|
||||
use Utopia\System\System;
|
||||
use WebSocket\Client as WebSocketClient;
|
||||
|
||||
class UsageTest extends Scope
|
||||
{
|
||||
@@ -227,6 +228,110 @@ class UsageTest extends Scope
|
||||
}
|
||||
|
||||
#[Depends('testUsersStats')]
|
||||
public function testPreparePresenceStats(array $data): array
|
||||
{
|
||||
$presenceKey = $this->getNewKey([
|
||||
'presences.read',
|
||||
'presences.write',
|
||||
]);
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
$apiUser = $this->getUser(true);
|
||||
$apiPresence = $this->client->call(
|
||||
Client::METHOD_PUT,
|
||||
'/presences/' . ID::unique(),
|
||||
[
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $presenceKey,
|
||||
],
|
||||
[
|
||||
'userId' => $apiUser['$id'],
|
||||
'status' => 'online',
|
||||
'metadata' => [
|
||||
'source' => 'api',
|
||||
'testRunId' => ID::unique(),
|
||||
],
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]
|
||||
);
|
||||
$this->assertEquals(200, $apiPresence['headers']['status-code']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
#[Depends('testPreparePresenceStats')]
|
||||
#[Retry(count: 1)]
|
||||
public function testPresenceStats(array $data): array
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$realtimeUser = $this->getUser(true);
|
||||
$realtime = new WebSocketClient(
|
||||
'ws://appwrite.test/v1/realtime?' . \http_build_query([
|
||||
'project' => $projectId,
|
||||
]),
|
||||
[
|
||||
'headers' => [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $realtimeUser['session'],
|
||||
],
|
||||
'timeout' => 2,
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
$connected = \json_decode($realtime->receive(), true);
|
||||
$this->assertSame('connected', $connected['type'] ?? null);
|
||||
|
||||
$presenceId = ID::unique();
|
||||
$realtime->send(\json_encode([
|
||||
'type' => 'presence',
|
||||
'data' => [
|
||||
'presenceId' => $presenceId,
|
||||
'status' => 'online',
|
||||
'metadata' => [
|
||||
'source' => 'realtime',
|
||||
'testRunId' => ID::unique(),
|
||||
],
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$response = \json_decode($realtime->receive(), true);
|
||||
$this->assertSame('response', $response['type'] ?? null);
|
||||
$this->assertSame('presence', $response['data']['to'] ?? null);
|
||||
$this->assertSame($presenceId, $response['data']['presence']['$id'] ?? null);
|
||||
|
||||
$this->assertEventually(function () {
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/presences/usage?range=90d',
|
||||
$this->getConsoleHeaders()
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('90d', $response['body']['range']);
|
||||
$this->assertEquals(90, count($response['body']['presences']));
|
||||
$this->assertEquals(2, $response['body']['usersOnlineTotal']);
|
||||
$this->assertEquals(2, $response['body']['presences'][array_key_last($response['body']['presences'])]['value']);
|
||||
$this->validateDates($response['body']['presences']);
|
||||
});
|
||||
} finally {
|
||||
$realtime->close();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
#[Depends('testPresenceStats')]
|
||||
public function testPrepareStorageStats(array $data): array
|
||||
{
|
||||
$requestsTotal = $data['requestsTotal'];
|
||||
|
||||
@@ -1026,123 +1026,101 @@ class AccountCustomClientTest extends Scope
|
||||
// Use fresh account for predictable log count
|
||||
$data = $this->createFreshAccountWithSession();
|
||||
$session = $data['session'];
|
||||
$headers = array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Test for SUCCESS
|
||||
*/
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]));
|
||||
$this->assertEventually(function () use ($headers) {
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/logs', $headers);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertIsArray($response['body']['logs']);
|
||||
$this->assertNotEmpty($response['body']['logs']);
|
||||
// Fresh account: session.create is always logged. user.create audit may or may not
|
||||
// be present depending on async audit processing timing.
|
||||
$logCount = count($response['body']['logs']);
|
||||
$this->assertContains($logCount, [1, 2]);
|
||||
$this->assertIsNumeric($response['body']['total']);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertIsArray($response['body']['logs']);
|
||||
$this->assertNotEmpty($response['body']['logs']);
|
||||
$logCount = count($response['body']['logs']);
|
||||
$this->assertContains($logCount, [1, 2]);
|
||||
$this->assertIsNumeric($response['body']['total']);
|
||||
|
||||
// Check session.create log (logs[0] - most recent)
|
||||
$this->assertEquals('Windows', $response['body']['logs'][0]['osName']);
|
||||
$this->assertEquals('WIN', $response['body']['logs'][0]['osCode']);
|
||||
$this->assertEquals('10', $response['body']['logs'][0]['osVersion']);
|
||||
$this->assertEquals('session.create', $response['body']['logs'][0]['event']);
|
||||
$this->assertEquals('Windows', $response['body']['logs'][0]['osName']);
|
||||
$this->assertEquals('WIN', $response['body']['logs'][0]['osCode']);
|
||||
$this->assertEquals('10', $response['body']['logs'][0]['osVersion']);
|
||||
|
||||
$this->assertEquals('browser', $response['body']['logs'][0]['clientType']);
|
||||
$this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']);
|
||||
$this->assertEquals('CH', $response['body']['logs'][0]['clientCode']);
|
||||
$this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']);
|
||||
$this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']);
|
||||
$this->assertEquals('browser', $response['body']['logs'][0]['clientType']);
|
||||
$this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']);
|
||||
$this->assertEquals('CH', $response['body']['logs'][0]['clientCode']);
|
||||
$this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']);
|
||||
$this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']);
|
||||
|
||||
$this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']);
|
||||
$this->assertEquals('', $response['body']['logs'][0]['deviceBrand']);
|
||||
$this->assertEquals('', $response['body']['logs'][0]['deviceModel']);
|
||||
$this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']);
|
||||
$this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']);
|
||||
$this->assertEquals('', $response['body']['logs'][0]['deviceBrand']);
|
||||
$this->assertEquals('', $response['body']['logs'][0]['deviceModel']);
|
||||
$this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']);
|
||||
|
||||
$this->assertEquals('--', $response['body']['logs'][0]['countryCode']);
|
||||
$this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']);
|
||||
$this->assertEquals('--', $response['body']['logs'][0]['countryCode']);
|
||||
$this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']);
|
||||
|
||||
if ($logCount === 2) {
|
||||
// Check user.create log (logs[1] - oldest)
|
||||
$this->assertEquals('user.create', $response['body']['logs'][1]['event']);
|
||||
$this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']);
|
||||
$this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time']));
|
||||
}
|
||||
if ($logCount === 2) {
|
||||
$this->assertEquals('user.create', $response['body']['logs'][1]['event']);
|
||||
$this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']);
|
||||
$this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time']));
|
||||
}
|
||||
|
||||
$responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]), [
|
||||
'queries' => [
|
||||
Query::limit(1)->toString()
|
||||
]
|
||||
]);
|
||||
$responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
|
||||
'queries' => [
|
||||
Query::limit(1)->toString()
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $responseLimit['headers']['status-code']);
|
||||
$this->assertIsArray($responseLimit['body']['logs']);
|
||||
$this->assertNotEmpty($responseLimit['body']['logs']);
|
||||
$this->assertCount(1, $responseLimit['body']['logs']);
|
||||
$this->assertIsNumeric($responseLimit['body']['total']);
|
||||
$this->assertEquals(200, $responseLimit['headers']['status-code']);
|
||||
$this->assertIsArray($responseLimit['body']['logs']);
|
||||
$this->assertNotEmpty($responseLimit['body']['logs']);
|
||||
$this->assertCount(1, $responseLimit['body']['logs']);
|
||||
$this->assertIsNumeric($responseLimit['body']['total']);
|
||||
|
||||
$this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]);
|
||||
$this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]);
|
||||
|
||||
$responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]), [
|
||||
'queries' => [
|
||||
Query::offset(1)->toString()
|
||||
]
|
||||
]);
|
||||
$responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
|
||||
'queries' => [
|
||||
Query::offset(1)->toString()
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertEquals($responseOffset['headers']['status-code'], 200);
|
||||
$this->assertIsArray($responseOffset['body']['logs']);
|
||||
// With offset(1), remaining logs = logCount - 1
|
||||
$this->assertCount($logCount - 1, $responseOffset['body']['logs']);
|
||||
$this->assertIsNumeric($responseOffset['body']['total']);
|
||||
$this->assertEquals(200, $responseOffset['headers']['status-code']);
|
||||
$this->assertIsArray($responseOffset['body']['logs']);
|
||||
$this->assertCount($logCount - 1, $responseOffset['body']['logs']);
|
||||
$this->assertIsNumeric($responseOffset['body']['total']);
|
||||
|
||||
if ($logCount === 2) {
|
||||
$this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]);
|
||||
}
|
||||
if ($logCount === 2) {
|
||||
$this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]);
|
||||
}
|
||||
|
||||
$responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]), [
|
||||
'queries' => [
|
||||
Query::offset(1)->toString(),
|
||||
Query::limit(1)->toString()
|
||||
]
|
||||
]);
|
||||
$responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
|
||||
'queries' => [
|
||||
Query::offset(1)->toString(),
|
||||
Query::limit(1)->toString()
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $responseLimitOffset['headers']['status-code']);
|
||||
$this->assertIsArray($responseLimitOffset['body']['logs']);
|
||||
// With offset(1)+limit(1), remaining logs = min(1, logCount - 1)
|
||||
$this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']);
|
||||
$this->assertIsNumeric($responseLimitOffset['body']['total']);
|
||||
$this->assertEquals(200, $responseLimitOffset['headers']['status-code']);
|
||||
$this->assertIsArray($responseLimitOffset['body']['logs']);
|
||||
$this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']);
|
||||
$this->assertIsNumeric($responseLimitOffset['body']['total']);
|
||||
|
||||
if ($logCount === 2) {
|
||||
$this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]);
|
||||
}
|
||||
if ($logCount === 2) {
|
||||
$this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test for total=false
|
||||
*/
|
||||
$logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]), [
|
||||
$logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
|
||||
'total' => false
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\GraphQL;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
|
||||
class PresenceTest extends Scope
|
||||
{
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
public function testUpsertPresenceSourceIsGraphql(): void
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$apiKey = $this->getNewKey(['presences.write']);
|
||||
$user = $this->getUser(true);
|
||||
|
||||
$payload = [
|
||||
'query' => <<<'GQL'
|
||||
mutation upsert($presenceId: String!, $userId: String!, $status: String!, $metadata: Json) {
|
||||
presencesUpsert(presenceId: $presenceId, userId: $userId, status: $status, metadata: $metadata) {
|
||||
_id
|
||||
userId
|
||||
status
|
||||
source
|
||||
}
|
||||
}
|
||||
GQL,
|
||||
'variables' => [
|
||||
'presenceId' => ID::unique(),
|
||||
'userId' => $user['$id'],
|
||||
'status' => 'online',
|
||||
'metadata' => [
|
||||
'testRunId' => ID::unique(),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/graphql', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $apiKey,
|
||||
], $payload);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('online', $response['body']['data']['presencesUpsert']['status']);
|
||||
$this->assertEquals('graphql', $response['body']['data']['presencesUpsert']['source']);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Presences;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideConsole;
|
||||
|
||||
class PresenceConsoleClientTest extends Scope
|
||||
{
|
||||
use ProjectCustom;
|
||||
use SideConsole;
|
||||
|
||||
public function testGetPresenceUsage(): void
|
||||
{
|
||||
$response = $this->client->call(Client::METHOD_GET, '/presences/usage', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'range' => '32h',
|
||||
]);
|
||||
|
||||
$this->assertEquals(400, $response['headers']['status-code']);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_GET, '/presences/usage', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'range' => '24h',
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('24h', $response['body']['range']);
|
||||
$this->assertCount(3, $response['body']);
|
||||
$this->assertIsNumeric($response['body']['usersOnlineTotal']);
|
||||
$this->assertIsArray($response['body']['presences']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Presences;
|
||||
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideClient;
|
||||
|
||||
class PresenceCustomClientTest extends Scope
|
||||
{
|
||||
use PresenceBase;
|
||||
use ProjectCustom;
|
||||
use SideClient;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Presences;
|
||||
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
|
||||
class PresenceCustomServerTest extends Scope
|
||||
{
|
||||
use PresenceBase;
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Presences;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
use Utopia\Console;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
|
||||
class PresenceExpiryTest extends Scope
|
||||
{
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
private static array $presenceApiKeyCache = [];
|
||||
|
||||
private function getPresenceApiKey(): string
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
if (!empty(self::$presenceApiKeyCache[$projectId])) {
|
||||
return self::$presenceApiKeyCache[$projectId];
|
||||
}
|
||||
|
||||
self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
|
||||
'presences.read',
|
||||
'presences.write',
|
||||
]);
|
||||
|
||||
return self::$presenceApiKeyCache[$projectId];
|
||||
}
|
||||
|
||||
public function testExpiredPresenceDeletedByMaintenance(): void
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$userId = $this->getUser()['$id'];
|
||||
// Set a near-future expiry to satisfy validation, then wait until it is in the past.
|
||||
$expiresAt = DateTime::format((new \DateTime())->modify('+2 seconds'));
|
||||
|
||||
$createServer = $this->client->call(
|
||||
Client::METHOD_PUT,
|
||||
'/presences/' . ID::unique(),
|
||||
[
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getPresenceApiKey(),
|
||||
],
|
||||
[
|
||||
'userId' => $userId,
|
||||
'status' => 'online',
|
||||
'metadata' => ['test' => 'presence-expiry'],
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $createServer['headers']['status-code']);
|
||||
$presenceIdServer = $createServer['body']['$id'];
|
||||
|
||||
$expireServer = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/presences/' . $presenceIdServer,
|
||||
[
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getPresenceApiKey(),
|
||||
],
|
||||
[
|
||||
'userId' => $userId,
|
||||
'expiresAt' => $expiresAt,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $expireServer['headers']['status-code']);
|
||||
$this->assertEquals(
|
||||
(new \DateTime($expiresAt))->getTimestamp(),
|
||||
(new \DateTime($expireServer['body']['expiresAt']))->getTimestamp()
|
||||
);
|
||||
|
||||
\sleep(3);
|
||||
|
||||
$stdout = '';
|
||||
$stderr = '';
|
||||
$code = Console::execute('docker exec appwrite maintenance --type=trigger', '', $stdout, $stderr);
|
||||
$this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)");
|
||||
|
||||
// Maintenance + delete workers are asynchronous; give extra time to observe cleanup.
|
||||
$this->assertEventually(function () use ($presenceIdServer, $projectId) {
|
||||
$getServer = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/presences/' . $presenceIdServer,
|
||||
[
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getPresenceApiKey(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertEquals(404, $getServer['headers']['status-code']);
|
||||
}, 30000, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Presences;
|
||||
|
||||
use Appwrite\Tests\Async\Exceptions\Critical;
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideClient;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Query;
|
||||
use WebSocket\Client as WebSocketClient;
|
||||
use WebSocket\TimeoutException;
|
||||
|
||||
class PresenceRealtimeClientTest extends Scope
|
||||
{
|
||||
use ProjectCustom;
|
||||
use SideClient;
|
||||
|
||||
private static array $presenceApiKeyCache = [];
|
||||
|
||||
private function bootstrapIsolatedProject(): array
|
||||
{
|
||||
$project = $this->getProject(true);
|
||||
self::$project = $project;
|
||||
|
||||
$user = $this->getUser(true);
|
||||
$headers = [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $project['$id'] . '=' . $user['session'],
|
||||
];
|
||||
|
||||
return [$project, $user, $headers];
|
||||
}
|
||||
|
||||
private function getServerHeaders(array $project): array
|
||||
{
|
||||
return [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $project['$id'],
|
||||
'x-appwrite-key' => $this->getPresenceApiKey($project),
|
||||
];
|
||||
}
|
||||
|
||||
private function getPresenceApiKey(array $project): string
|
||||
{
|
||||
$projectId = $project['$id'];
|
||||
|
||||
if (!empty(self::$presenceApiKeyCache[$projectId])) {
|
||||
return self::$presenceApiKeyCache[$projectId];
|
||||
}
|
||||
|
||||
// Realtime tests validate HTTP reads of presences; those endpoints require `presences.read`.
|
||||
self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
|
||||
'presences.read',
|
||||
'presences.write',
|
||||
]);
|
||||
|
||||
return self::$presenceApiKeyCache[$projectId];
|
||||
}
|
||||
|
||||
private function connectRealtimeAndSubscribe(
|
||||
array $project,
|
||||
array $headers,
|
||||
array $channels = [],
|
||||
int $timeout = 1
|
||||
): WebSocketClient {
|
||||
$queryString = \http_build_query([
|
||||
'project' => $project['$id'],
|
||||
]);
|
||||
|
||||
$client = new WebSocketClient(
|
||||
'ws://appwrite.test/v1/realtime?' . $queryString,
|
||||
[
|
||||
'headers' => $headers,
|
||||
'timeout' => $timeout,
|
||||
]
|
||||
);
|
||||
|
||||
$connected = \json_decode($client->receive(), true);
|
||||
$this->assertSame('connected', $connected['type'] ?? null);
|
||||
|
||||
if (empty($channels)) {
|
||||
return $client;
|
||||
}
|
||||
|
||||
$client->send(\json_encode([
|
||||
'type' => 'subscribe',
|
||||
'data' => [[
|
||||
'channels' => $channels,
|
||||
]],
|
||||
]));
|
||||
|
||||
$subscribeResponse = \json_decode($client->receive(), true);
|
||||
$this->assertSame('response', $subscribeResponse['type'] ?? null);
|
||||
$this->assertSame('subscribe', $subscribeResponse['data']['to'] ?? null);
|
||||
$this->assertTrue($subscribeResponse['data']['success'] ?? false);
|
||||
$this->assertNotEmpty($subscribeResponse['data']['subscriptions'] ?? []);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function receiveUntil(
|
||||
WebSocketClient $client,
|
||||
callable $match,
|
||||
int $timeoutMs = 800,
|
||||
int $pollMs = 50
|
||||
): array {
|
||||
$deadline = \microtime(true) + ($timeoutMs / 1000);
|
||||
$lastMessage = [];
|
||||
|
||||
while (\microtime(true) < $deadline) {
|
||||
try {
|
||||
$message = \json_decode($client->receive(), true);
|
||||
} catch (TimeoutException) {
|
||||
\usleep($pollMs * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!\is_array($message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastMessage = $message;
|
||||
if ($match($message)) {
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
|
||||
$this->fail('Timed out waiting for expected websocket frame. Last frame: ' . \json_encode($lastMessage));
|
||||
}
|
||||
|
||||
private function assertQuietFor(WebSocketClient $client, callable $forbidden, int $timeoutMs = 150): void
|
||||
{
|
||||
$deadline = \microtime(true) + ($timeoutMs / 1000);
|
||||
while (\microtime(true) < $deadline) {
|
||||
try {
|
||||
$message = \json_decode($client->receive(), true);
|
||||
} catch (TimeoutException) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!\is_array($message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($forbidden($message)) {
|
||||
$this->fail('Received forbidden websocket frame: ' . \json_encode($message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPresenceRealtimeEvent(
|
||||
array $event,
|
||||
string $presenceId,
|
||||
string $action,
|
||||
string $status,
|
||||
array $metadata,
|
||||
string $expectedUserId
|
||||
): void {
|
||||
$this->assertSame('event', $event['type'] ?? null);
|
||||
$this->assertContains('presences', $event['data']['channels'] ?? []);
|
||||
$this->assertContains('presences.' . $presenceId, $event['data']['channels'] ?? []);
|
||||
$this->assertContains('presences.' . $presenceId . '.' . $action, $event['data']['events'] ?? []);
|
||||
$this->assertSame($presenceId, $event['data']['payload']['$id'] ?? null);
|
||||
$this->assertSame($status, $event['data']['payload']['status'] ?? null);
|
||||
$this->assertSame($metadata, $event['data']['payload']['metadata'] ?? []);
|
||||
$this->assertSame($expectedUserId, $event['data']['payload']['userId'] ?? null);
|
||||
}
|
||||
|
||||
private function receivePresenceEvent(
|
||||
WebSocketClient $client,
|
||||
string $presenceId,
|
||||
string $action,
|
||||
string $status,
|
||||
array $metadata,
|
||||
string $expectedUserId,
|
||||
int $timeoutMs = 2500
|
||||
): array {
|
||||
$event = $this->receiveUntil(
|
||||
$client,
|
||||
fn (array $message): bool => ($message['type'] ?? null) === 'event'
|
||||
&& ($message['data']['payload']['$id'] ?? null) === $presenceId
|
||||
&& \in_array('presences.' . $presenceId . '.' . $action, $message['data']['events'] ?? [], true),
|
||||
$timeoutMs
|
||||
);
|
||||
|
||||
$this->assertPresenceRealtimeEvent($event, $presenceId, $action, $status, $metadata, $expectedUserId);
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function collectPresenceOutcome(
|
||||
WebSocketClient $client,
|
||||
string $presenceId,
|
||||
string $expectedStatus,
|
||||
array $expectedMetadata,
|
||||
string $expectedUserId
|
||||
): void {
|
||||
$response = null;
|
||||
$event = null;
|
||||
|
||||
$this->receiveUntil($client, function (array $message) use (
|
||||
&$response,
|
||||
&$event,
|
||||
$presenceId,
|
||||
$expectedStatus,
|
||||
$expectedMetadata,
|
||||
$expectedUserId
|
||||
): bool {
|
||||
$type = $message['type'] ?? null;
|
||||
if ($type === 'response' && ($message['data']['to'] ?? null) === 'presence') {
|
||||
if (($message['data']['presence']['$id'] ?? null) !== $presenceId) {
|
||||
return false;
|
||||
}
|
||||
$this->assertSame($expectedStatus, $message['data']['presence']['status'] ?? null);
|
||||
$this->assertSame($expectedMetadata, $message['data']['presence']['metadata'] ?? null);
|
||||
$response = $message;
|
||||
}
|
||||
|
||||
if ($type === 'event' && ($message['data']['payload']['$id'] ?? null) === $presenceId) {
|
||||
if (!\in_array('presences.' . $presenceId . '.upsert', $message['data']['events'] ?? [], true)) {
|
||||
return false;
|
||||
}
|
||||
$this->assertPresenceRealtimeEvent($message, $presenceId, 'upsert', $expectedStatus, $expectedMetadata, $expectedUserId);
|
||||
$event = $message;
|
||||
}
|
||||
|
||||
return $response !== null && $event !== null;
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
private function receiveErrorMessage(WebSocketClient $client): array
|
||||
{
|
||||
$error = $this->receiveUntil(
|
||||
$client,
|
||||
fn (array $message): bool => ($message['type'] ?? null) === 'error',
|
||||
3000
|
||||
);
|
||||
$this->assertSame('error', $error['type'] ?? null);
|
||||
return $error;
|
||||
}
|
||||
|
||||
private function sendPresenceMessage(
|
||||
WebSocketClient $client,
|
||||
string $presenceId,
|
||||
string $status,
|
||||
array $metadata,
|
||||
array $permissions
|
||||
): void {
|
||||
$client->send(\json_encode([
|
||||
'type' => 'presence',
|
||||
'data' => [
|
||||
'presenceId' => $presenceId,
|
||||
'status' => $status,
|
||||
'metadata' => $metadata,
|
||||
'permissions' => $permissions,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
private function getPresencePermissions(string|Role $readRole): array
|
||||
{
|
||||
return [
|
||||
Permission::read($readRole),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
];
|
||||
}
|
||||
|
||||
public function testPresenceUpsertSenderGetsResponseAndEvent(): void
|
||||
{
|
||||
[$project, $user, $headers] = $this->bootstrapIsolatedProject();
|
||||
$presenceId = ID::unique();
|
||||
$metadata = ['testRunId' => ID::unique(), 'case' => 'upsert-basic'];
|
||||
|
||||
$publisher = $this->connectRealtimeAndSubscribe(
|
||||
$project,
|
||||
$headers,
|
||||
['presences', 'presences.' . $presenceId],
|
||||
timeout: 2
|
||||
);
|
||||
|
||||
try {
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$presenceId,
|
||||
'online',
|
||||
$metadata,
|
||||
$this->getPresencePermissions(Role::any())
|
||||
);
|
||||
|
||||
$this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']);
|
||||
|
||||
$read = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/presences/' . $presenceId,
|
||||
$this->getServerHeaders($project)
|
||||
);
|
||||
|
||||
$this->assertSame(200, $read['headers']['status-code']);
|
||||
$this->assertSame($presenceId, $read['body']['$id']);
|
||||
$this->assertSame($user['$id'], $read['body']['userId']);
|
||||
$this->assertSame('online', $read['body']['status']);
|
||||
$this->assertSame($metadata, $read['body']['metadata']);
|
||||
} finally {
|
||||
$publisher->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testPresenceUpsertSameUserUpdatesSingleRecord(): void
|
||||
{
|
||||
[$project, $user, $headers] = $this->bootstrapIsolatedProject();
|
||||
$firstPresenceId = ID::unique();
|
||||
$secondPresenceId = ID::unique();
|
||||
$marker = ID::unique();
|
||||
|
||||
$publisher = $this->connectRealtimeAndSubscribe(
|
||||
$project,
|
||||
$headers,
|
||||
['presences', 'presences.' . $firstPresenceId, 'presences.' . $secondPresenceId],
|
||||
timeout: 2
|
||||
);
|
||||
|
||||
try {
|
||||
$firstMetadata = ['testRunId' => $marker, 'step' => 'first'];
|
||||
$secondMetadata = ['testRunId' => $marker, 'step' => 'second'];
|
||||
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$firstPresenceId,
|
||||
'away',
|
||||
$firstMetadata,
|
||||
$this->getPresencePermissions(Role::any())
|
||||
);
|
||||
$this->collectPresenceOutcome($publisher, $firstPresenceId, 'away', $firstMetadata, $user['$id']);
|
||||
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$secondPresenceId,
|
||||
'busy',
|
||||
$secondMetadata,
|
||||
$this->getPresencePermissions(Role::any())
|
||||
);
|
||||
// The server keeps one row per user keyed by userInternalId and anchors $id to the
|
||||
// first claim, so the second upsert's response/event come back under $firstPresenceId.
|
||||
$this->collectPresenceOutcome($publisher, $firstPresenceId, 'busy', $secondMetadata, $user['$id']);
|
||||
|
||||
$list = $this->client->call(
|
||||
Client::METHOD_GET,
|
||||
'/presences',
|
||||
$this->getServerHeaders($project),
|
||||
[
|
||||
'queries' => [
|
||||
Query::equal('userId', [$user['$id']])->toString(),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $list['headers']['status-code']);
|
||||
$this->assertSame(1, $list['body']['total']);
|
||||
$this->assertSame($user['$id'], $list['body']['presences'][0]['userId']);
|
||||
$this->assertSame('busy', $list['body']['presences'][0]['status']);
|
||||
$this->assertSame($secondMetadata, $list['body']['presences'][0]['metadata']);
|
||||
} finally {
|
||||
$publisher->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testPresenceValidationErrorsReturnErrorOnly(): void
|
||||
{
|
||||
[$project, , $headers] = $this->bootstrapIsolatedProject();
|
||||
$presenceId = ID::unique();
|
||||
$client = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 2);
|
||||
|
||||
try {
|
||||
$client->send(\json_encode([
|
||||
'type' => 'presence',
|
||||
'data' => [
|
||||
'presenceId' => $presenceId,
|
||||
'metadata' => [
|
||||
'testRunId' => ID::unique(),
|
||||
],
|
||||
],
|
||||
]));
|
||||
$missingStatus = $this->receiveErrorMessage($client);
|
||||
$this->assertStringContainsString('Payload is not valid. Status is required', $missingStatus['data']['message'] ?? '');
|
||||
$this->assertQuietFor(
|
||||
$client,
|
||||
fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
|
||||
&& ($frame['data']['payload']['$id'] ?? null) === $presenceId
|
||||
);
|
||||
|
||||
$client->send(\json_encode([
|
||||
'type' => 'presence',
|
||||
'data' => [
|
||||
'presenceId' => $presenceId,
|
||||
'status' => 'online',
|
||||
'permissions' => 'invalid',
|
||||
],
|
||||
]));
|
||||
$invalidPermissions = $this->receiveErrorMessage($client);
|
||||
$this->assertStringContainsString('permissions: Permissions must be an array of strings', $invalidPermissions['data']['message'] ?? '');
|
||||
$this->assertQuietFor(
|
||||
$client,
|
||||
fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
|
||||
&& ($frame['data']['payload']['$id'] ?? null) === $presenceId
|
||||
);
|
||||
} finally {
|
||||
$client->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testPresenceUnauthenticatedUserGetsAuthorizationError(): void
|
||||
{
|
||||
$project = $this->getProject(true);
|
||||
self::$project = $project;
|
||||
|
||||
$presenceId = ID::unique();
|
||||
$client = $this->connectRealtimeAndSubscribe(
|
||||
$project,
|
||||
['origin' => 'http://localhost'],
|
||||
['presences', 'presences.' . $presenceId],
|
||||
timeout: 2
|
||||
);
|
||||
|
||||
try {
|
||||
$client->send(\json_encode([
|
||||
'type' => 'presence',
|
||||
'data' => [
|
||||
'presenceId' => $presenceId,
|
||||
'status' => 'online',
|
||||
'metadata' => ['testRunId' => ID::unique()],
|
||||
],
|
||||
]));
|
||||
|
||||
$error = $this->receiveErrorMessage($client);
|
||||
$this->assertSame(401, $error['data']['code'] ?? null);
|
||||
$this->assertSame('User must be authorized', $error['data']['message'] ?? null);
|
||||
|
||||
$this->assertQuietFor(
|
||||
$client,
|
||||
fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
|
||||
&& ($frame['data']['payload']['$id'] ?? null) === $presenceId
|
||||
);
|
||||
} finally {
|
||||
$client->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testChannelParsingChannelsAndEvents(): void
|
||||
{
|
||||
[$project, $user, $headers] = $this->bootstrapIsolatedProject();
|
||||
$presenceId = ID::unique();
|
||||
$listener = $this->connectRealtimeAndSubscribe(
|
||||
$project,
|
||||
$headers,
|
||||
['presences', 'presences.' . $presenceId],
|
||||
timeout: 2
|
||||
);
|
||||
|
||||
try {
|
||||
$createMetadata = ['testRunId' => ID::unique(), 'source' => 'channel-create'];
|
||||
$updateMetadata = ['testRunId' => $createMetadata['testRunId'], 'source' => 'channel-update'];
|
||||
|
||||
$create = $this->client->call(
|
||||
Client::METHOD_PUT,
|
||||
'/presences/' . $presenceId,
|
||||
$this->getServerHeaders($project),
|
||||
[
|
||||
'userId' => $user['$id'],
|
||||
'status' => 'online',
|
||||
'metadata' => $createMetadata,
|
||||
'permissions' => $this->getPresencePermissions(Role::any()),
|
||||
]
|
||||
);
|
||||
$this->assertSame(200, $create['headers']['status-code']);
|
||||
$this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $createMetadata, $user['$id']);
|
||||
|
||||
$update = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/presences/' . $presenceId,
|
||||
$this->getServerHeaders($project),
|
||||
[
|
||||
'status' => 'away',
|
||||
'metadata' => $updateMetadata,
|
||||
]
|
||||
);
|
||||
$this->assertSame(200, $update['headers']['status-code']);
|
||||
$this->receivePresenceEvent($listener, $presenceId, 'update', 'away', $updateMetadata, $user['$id']);
|
||||
|
||||
$delete = $this->client->call(
|
||||
Client::METHOD_DELETE,
|
||||
'/presences/' . $presenceId,
|
||||
$this->getServerHeaders($project)
|
||||
);
|
||||
$this->assertSame(204, $delete['headers']['status-code']);
|
||||
$this->receivePresenceEvent($listener, $presenceId, 'delete', 'away', $updateMetadata, $user['$id']);
|
||||
} finally {
|
||||
$listener->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testPresencePermissionsReceiverRouting(): void
|
||||
{
|
||||
[$project, $user1, $user1Headers] = $this->bootstrapIsolatedProject();
|
||||
$user2 = $this->getUser(true);
|
||||
|
||||
$user2Headers = [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $project['$id'] . '=' . $user2['session'],
|
||||
];
|
||||
|
||||
$presenceIdAny = ID::unique();
|
||||
$presenceIdOwner = ID::unique();
|
||||
|
||||
$channels = [
|
||||
'presences',
|
||||
'presences.' . $presenceIdAny,
|
||||
'presences.' . $presenceIdOwner,
|
||||
];
|
||||
|
||||
$publisher = $this->connectRealtimeAndSubscribe($project, $user1Headers, ['presences'], timeout: 1);
|
||||
$listener1 = $this->connectRealtimeAndSubscribe($project, $user1Headers, $channels, timeout: 1);
|
||||
$listener2 = $this->connectRealtimeAndSubscribe($project, $user2Headers, $channels, timeout: 1);
|
||||
|
||||
try {
|
||||
$metadataAny = ['testRunId' => ID::unique(), 'visibility' => 'any'];
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$presenceIdAny,
|
||||
'online',
|
||||
$metadataAny,
|
||||
$this->getPresencePermissions(Role::any())
|
||||
);
|
||||
$this->collectPresenceOutcome($publisher, $presenceIdAny, 'online', $metadataAny, $user1['$id']);
|
||||
$this->receivePresenceEvent($listener1, $presenceIdAny, 'upsert', 'online', $metadataAny, $user1['$id']);
|
||||
$this->receivePresenceEvent($listener2, $presenceIdAny, 'upsert', 'online', $metadataAny, $user1['$id']);
|
||||
|
||||
$metadataOwner = ['testRunId' => ID::unique(), 'visibility' => 'owner'];
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$presenceIdOwner,
|
||||
'busy',
|
||||
$metadataOwner,
|
||||
$this->getPresencePermissions(Role::user($user1['$id']))
|
||||
);
|
||||
// Same user, so the server reuses the original record's $id ($presenceIdAny);
|
||||
// only permissions/status/metadata change — which is what permission routing should filter on.
|
||||
$this->collectPresenceOutcome($publisher, $presenceIdAny, 'busy', $metadataOwner, $user1['$id']);
|
||||
$this->receivePresenceEvent($listener1, $presenceIdAny, 'upsert', 'busy', $metadataOwner, $user1['$id']);
|
||||
$this->assertQuietFor(
|
||||
$listener2,
|
||||
fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
|
||||
&& ($frame['data']['payload']['$id'] ?? null) === $presenceIdAny
|
||||
&& ($frame['data']['payload']['metadata']['visibility'] ?? null) === 'owner'
|
||||
);
|
||||
} finally {
|
||||
$publisher->close();
|
||||
$listener1->close();
|
||||
$listener2->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testPresenceCloseEmitsDeleteEvent(): void
|
||||
{
|
||||
[$project, $user, $headers] = $this->bootstrapIsolatedProject();
|
||||
$presenceId = ID::unique();
|
||||
$metadata = ['testRunId' => ID::unique(), 'source' => 'close-delete'];
|
||||
|
||||
$publisher = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
|
||||
$listener = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
|
||||
|
||||
try {
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$presenceId,
|
||||
'online',
|
||||
$metadata,
|
||||
$this->getPresencePermissions(Role::any())
|
||||
);
|
||||
$this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']);
|
||||
$this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $metadata, $user['$id']);
|
||||
|
||||
$publisher->close();
|
||||
|
||||
$this->receivePresenceEvent($listener, $presenceId, 'delete', 'online', $metadata, $user['$id'], timeoutMs: 3000);
|
||||
} finally {
|
||||
$listener->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testHttpDeleteThenCloseDoesNotDuplicateDeleteEvent(): void
|
||||
{
|
||||
[$project, $user, $headers] = $this->bootstrapIsolatedProject();
|
||||
$presenceId = ID::unique();
|
||||
$metadata = ['testRunId' => ID::unique(), 'source' => 'http-delete-then-close'];
|
||||
|
||||
$publisher = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
|
||||
$listener = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
|
||||
|
||||
try {
|
||||
// Publish a presence over WebSocket so the realtime worker tracks it in
|
||||
// its in-memory connection map under the publisher connection.
|
||||
$this->sendPresenceMessage(
|
||||
$publisher,
|
||||
$presenceId,
|
||||
'online',
|
||||
$metadata,
|
||||
$this->getPresencePermissions(Role::any())
|
||||
);
|
||||
$this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']);
|
||||
$this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $metadata, $user['$id']);
|
||||
|
||||
// HTTP DELETE removes the row from the DB and emits the delete event via pubsub.
|
||||
// The realtime worker is expected to strip the presence from the publisher's
|
||||
// in-memory connection state when it processes the pubsub message.
|
||||
$delete = $this->client->call(
|
||||
Client::METHOD_DELETE,
|
||||
'/presences/' . $presenceId,
|
||||
$this->getServerHeaders($project)
|
||||
);
|
||||
$this->assertSame(204, $delete['headers']['status-code']);
|
||||
|
||||
// Synchronization point: wait for the listener to receive the legitimate
|
||||
// delete event before closing the publisher. Redis pubsub broadcasts to
|
||||
// every realtime worker simultaneously, so the listener's worker observing
|
||||
// the event implies the publisher's worker has also processed it (and run
|
||||
// the in-memory cleanup) by the time onClose fires.
|
||||
$deleteEvents = [];
|
||||
$deleteEvents[] = $this->receivePresenceEvent($listener, $presenceId, 'delete', 'online', $metadata, $user['$id']);
|
||||
|
||||
$publisher->close();
|
||||
|
||||
// Watch for any additional presences.{id}.delete frame. A second one would
|
||||
// be the regression: onClose re-firing the event for a presence already
|
||||
// removed via HTTP DELETE.
|
||||
$deadline = \microtime(true) + 2.0;
|
||||
|
||||
$this->assertEventually(
|
||||
function () use ($listener, $presenceId, $deadline, &$deleteEvents): void {
|
||||
try {
|
||||
$raw = $listener->receive();
|
||||
$frame = \json_decode($raw, true);
|
||||
if (
|
||||
\is_array($frame)
|
||||
&& ($frame['type'] ?? null) === 'event'
|
||||
&& ($frame['data']['payload']['$id'] ?? null) === $presenceId
|
||||
&& \in_array('presences.' . $presenceId . '.delete', $frame['data']['events'] ?? [], true)
|
||||
) {
|
||||
$deleteEvents[] = $frame;
|
||||
if (\count($deleteEvents) > 1) {
|
||||
throw new Critical(
|
||||
'Duplicate presence delete event after HTTP DELETE + WebSocket close: '
|
||||
. \json_encode($frame)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (TimeoutException) {
|
||||
// No frame this poll; fall through to deadline check.
|
||||
}
|
||||
|
||||
if (\microtime(true) < $deadline) {
|
||||
// Throw a non-Critical exception so assertEventually retries.
|
||||
throw new \RuntimeException('still watching for duplicate delete event');
|
||||
}
|
||||
},
|
||||
timeoutMs: 3000,
|
||||
waitMs: 0
|
||||
);
|
||||
|
||||
$this->assertCount(
|
||||
1,
|
||||
$deleteEvents,
|
||||
'Expected exactly one presences.' . $presenceId . '.delete event; got ' . \count($deleteEvents)
|
||||
);
|
||||
$this->assertPresenceRealtimeEvent($deleteEvents[0], $presenceId, 'delete', 'online', $metadata, $user['$id']);
|
||||
} finally {
|
||||
$listener->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,7 @@ trait SMTPBase
|
||||
|
||||
public function testUpdateSMTPEmptySenderName(): void
|
||||
{
|
||||
// Empty sender name is valid — PHPMailer accepts '' as display name.
|
||||
$response = $this->updateSMTP(
|
||||
senderName: '',
|
||||
senderEmail: 'sender@example.com',
|
||||
@@ -301,11 +302,17 @@ trait SMTPBase
|
||||
port: 1025,
|
||||
);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame('', $response['body']['smtpSenderName']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateSMTP(enabled: false);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPEmptySenderEmail(): void
|
||||
{
|
||||
// Empty senderEmail clears the stored value; connection test is skipped when
|
||||
// there is no valid From address, so this is accepted even without enabled=false.
|
||||
$response = $this->updateSMTP(
|
||||
senderName: 'Test',
|
||||
senderEmail: '',
|
||||
@@ -313,7 +320,11 @@ trait SMTPBase
|
||||
port: 1025,
|
||||
);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame('', $response['body']['smtpSenderEmail']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateSMTP(enabled: false);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPEmptyHost(): void
|
||||
@@ -353,6 +364,59 @@ trait SMTPBase
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPReplyToEmailCanBeCleared(): void
|
||||
{
|
||||
// Step 1: Set a custom replyToEmail.
|
||||
$set = $this->updateSMTP(
|
||||
senderName: 'Test Sender',
|
||||
senderEmail: 'sender@example.com',
|
||||
host: 'maildev',
|
||||
port: 1025,
|
||||
replyToEmail: 'reply@example.com',
|
||||
);
|
||||
$this->assertSame(200, $set['headers']['status-code']);
|
||||
$this->assertSame('reply@example.com', $set['body']['smtpReplyToEmail']);
|
||||
|
||||
// Step 2: Clear it with an empty string.
|
||||
$clear = $this->updateSMTP(replyToEmail: '');
|
||||
$this->assertSame(200, $clear['headers']['status-code']);
|
||||
$this->assertSame('', $clear['body']['smtpReplyToEmail']);
|
||||
|
||||
// Step 3: Verify the cleared value persists.
|
||||
$verify = $this->updateSMTP();
|
||||
$this->assertSame(200, $verify['headers']['status-code']);
|
||||
$this->assertSame('', $verify['body']['smtpReplyToEmail']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateSMTP(enabled: false);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPSenderEmailCanBeClearedWhenDisabled(): void
|
||||
{
|
||||
// Step 1: Configure SMTP with a sender email, then disable it.
|
||||
$this->updateSMTP(
|
||||
senderName: 'Test Sender',
|
||||
senderEmail: 'sender@example.com',
|
||||
host: 'maildev',
|
||||
port: 1025,
|
||||
enabled: false,
|
||||
);
|
||||
|
||||
// Step 2: Clear senderEmail while keeping SMTP disabled.
|
||||
// enabled=false skips the PHPMailer connection check so empty senderEmail is valid.
|
||||
$clear = $this->updateSMTP(
|
||||
senderEmail: '',
|
||||
enabled: false,
|
||||
);
|
||||
$this->assertSame(200, $clear['headers']['status-code']);
|
||||
$this->assertSame('', $clear['body']['smtpSenderEmail']);
|
||||
|
||||
// Step 3: Verify the cleared value persists.
|
||||
$verify = $this->updateSMTP(enabled: false);
|
||||
$this->assertSame(200, $verify['headers']['status-code']);
|
||||
$this->assertSame('', $verify['body']['smtpSenderEmail']);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPInvalidSecure(): void
|
||||
{
|
||||
$response = $this->updateSMTP(
|
||||
@@ -461,6 +525,7 @@ trait SMTPBase
|
||||
|
||||
public function testUpdateSMTPUsernameEmpty(): void
|
||||
{
|
||||
// Empty string clears a previously-set username (no-auth SMTP).
|
||||
$response = $this->updateSMTP(
|
||||
senderName: 'Test',
|
||||
senderEmail: 'sender@example.com',
|
||||
@@ -469,7 +534,11 @@ trait SMTPBase
|
||||
username: '',
|
||||
);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame('', $response['body']['smtpUsername']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateSMTP(enabled: false);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPPasswordMinLength(): void
|
||||
@@ -524,6 +593,7 @@ trait SMTPBase
|
||||
|
||||
public function testUpdateSMTPPasswordEmpty(): void
|
||||
{
|
||||
// Empty string clears a previously-set password (no-auth SMTP).
|
||||
$response = $this->updateSMTP(
|
||||
senderName: 'Test',
|
||||
senderEmail: 'sender@example.com',
|
||||
@@ -532,7 +602,45 @@ trait SMTPBase
|
||||
password: '',
|
||||
);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
// smtpPassword is write-only and never echoed back.
|
||||
$this->assertSame('', $response['body']['smtpPassword']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateSMTP(enabled: false);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPCredentialsCanBeCleared(): void
|
||||
{
|
||||
// Step 1: Set username and password.
|
||||
$set = $this->updateSMTP(
|
||||
senderName: 'Test Sender',
|
||||
senderEmail: 'sender@example.com',
|
||||
host: 'maildev',
|
||||
port: 1025,
|
||||
username: 'myuser',
|
||||
password: 'mypassword',
|
||||
);
|
||||
$this->assertSame(200, $set['headers']['status-code']);
|
||||
$this->assertSame('myuser', $set['body']['smtpUsername']);
|
||||
|
||||
// Step 2: Clear both credentials by passing empty strings.
|
||||
$clear = $this->updateSMTP(
|
||||
username: '',
|
||||
password: '',
|
||||
);
|
||||
$this->assertSame(200, $clear['headers']['status-code']);
|
||||
$this->assertSame('', $clear['body']['smtpUsername']);
|
||||
// smtpPassword is write-only and never echoed back regardless.
|
||||
$this->assertSame('', $clear['body']['smtpPassword']);
|
||||
|
||||
// Step 3: Verify the cleared username persists (a no-params PATCH must not restore it).
|
||||
$verify = $this->updateSMTP();
|
||||
$this->assertSame(200, $verify['headers']['status-code']);
|
||||
$this->assertSame('', $verify['body']['smtpUsername']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateSMTP(enabled: false);
|
||||
}
|
||||
|
||||
public function testUpdateSMTPWithoutSecure(): void
|
||||
|
||||
@@ -548,6 +548,59 @@ trait TemplatesBase
|
||||
$this->assertSame(401, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateEmailTemplateSenderFieldsCanBeCleared(): void
|
||||
{
|
||||
$this->ensureSMTPEnabled();
|
||||
|
||||
// Step 1: Set a custom en verification template with sender and reply-to fields.
|
||||
$first = $this->updateEmailTemplate(
|
||||
templateId: 'verification',
|
||||
locale: 'en',
|
||||
subject: 'Verify your email',
|
||||
message: 'Please verify: {{url}}',
|
||||
senderName: 'Custom Sender',
|
||||
senderEmail: 'custom-sender@appwrite.io',
|
||||
replyToName: 'Custom Reply',
|
||||
replyToEmail: 'custom-reply@appwrite.io',
|
||||
);
|
||||
$this->assertSame(200, $first['headers']['status-code']);
|
||||
$this->assertSame('Custom Sender', $first['body']['senderName']);
|
||||
$this->assertSame('custom-sender@appwrite.io', $first['body']['senderEmail']);
|
||||
$this->assertSame('Custom Reply', $first['body']['replyToName']);
|
||||
$this->assertSame('custom-reply@appwrite.io', $first['body']['replyToEmail']);
|
||||
|
||||
// Step 2: GET en verification template and ensure it reflects the custom values.
|
||||
$get = $this->getEmailTemplate('verification', 'en');
|
||||
$this->assertSame(200, $get['headers']['status-code']);
|
||||
$this->assertSame('Custom Sender', $get['body']['senderName']);
|
||||
$this->assertSame('custom-sender@appwrite.io', $get['body']['senderEmail']);
|
||||
$this->assertSame('Custom Reply', $get['body']['replyToName']);
|
||||
$this->assertSame('custom-reply@appwrite.io', $get['body']['replyToEmail']);
|
||||
|
||||
// Step 3: Update the same template, clearing sender and reply-to fields to empty strings.
|
||||
$clear = $this->updateEmailTemplate(
|
||||
templateId: 'verification',
|
||||
locale: 'en',
|
||||
senderName: '',
|
||||
senderEmail: '',
|
||||
replyToName: '',
|
||||
replyToEmail: '',
|
||||
);
|
||||
$this->assertSame(200, $clear['headers']['status-code']);
|
||||
$this->assertSame('', $clear['body']['senderName']);
|
||||
$this->assertSame('', $clear['body']['senderEmail']);
|
||||
$this->assertSame('', $clear['body']['replyToName']);
|
||||
$this->assertSame('', $clear['body']['replyToEmail']);
|
||||
|
||||
// Step 4: GET again to confirm the cleared values persist.
|
||||
$getAfter = $this->getEmailTemplate('verification', 'en');
|
||||
$this->assertSame(200, $getAfter['headers']['status-code']);
|
||||
$this->assertSame('', $getAfter['body']['senderName']);
|
||||
$this->assertSame('', $getAfter['body']['senderEmail']);
|
||||
$this->assertSame('', $getAfter['body']['replyToName']);
|
||||
$this->assertSame('', $getAfter['body']['replyToEmail']);
|
||||
}
|
||||
|
||||
public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void
|
||||
{
|
||||
// Custom templates only make sense alongside a custom SMTP configuration.
|
||||
|
||||
@@ -118,7 +118,7 @@ trait WebhooksBase
|
||||
$this->assertCount(2, $collection['body']['attributes']);
|
||||
$this->assertEquals('available', $collection['body']['attributes'][0]['status']);
|
||||
$this->assertEquals('available', $collection['body']['attributes'][1]['status']);
|
||||
}, 15000, 500);
|
||||
}, 60000, 500);
|
||||
|
||||
return ['databaseId' => $databaseId, 'actorsId' => $actorsId];
|
||||
}
|
||||
@@ -192,7 +192,7 @@ trait WebhooksBase
|
||||
$this->assertCount(2, $table['body']['columns']);
|
||||
$this->assertEquals('available', $table['body']['columns'][0]['status']);
|
||||
$this->assertEquals('available', $table['body']['columns'][1]['status']);
|
||||
}, 15000, 500);
|
||||
}, 60000, 500);
|
||||
|
||||
return ['databaseId' => $databaseId, 'actorsId' => $actorsId];
|
||||
}
|
||||
@@ -529,7 +529,7 @@ trait WebhooksBase
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
|
||||
$this->assertNotEmpty($webhook['data']['key']);
|
||||
$this->assertEquals($webhook['data']['key'], 'extra');
|
||||
}, 15000, 500);
|
||||
}, 30000, 500);
|
||||
|
||||
$removed = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/' . $extra['body']['key'], array_merge([
|
||||
'content-type' => 'application/json',
|
||||
@@ -896,7 +896,7 @@ trait WebhooksBase
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
|
||||
$this->assertNotEmpty($webhook['data']['key']);
|
||||
$this->assertEquals($webhook['data']['key'], 'extra');
|
||||
}, 15000, 500);
|
||||
}, 30000, 500);
|
||||
|
||||
$removed = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/' . $extra['body']['key'], array_merge([
|
||||
'content-type' => 'application/json',
|
||||
@@ -1833,6 +1833,6 @@ trait WebhooksBase
|
||||
// assert that the webhook is now disabled after 10 consecutive failures
|
||||
$this->assertEquals($webhook['body']['enabled'], false);
|
||||
$this->assertEquals($webhook['body']['attempts'], 10);
|
||||
}, 15000, 500);
|
||||
}, 30000, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ trait ProxyBase
|
||||
$this->assertEquals(200, $rules['headers']['status-code']);
|
||||
$this->assertEquals(2, $rules['body']['total']);
|
||||
|
||||
// Delete rules before the site to avoid cascade-delete races.
|
||||
$this->cleanupRule($ruleId301);
|
||||
$this->cleanupRule($ruleId307);
|
||||
$this->cleanupSite($siteId);
|
||||
|
||||
@@ -257,7 +257,7 @@ class RealtimeConsoleClientTest extends Scope
|
||||
$this->assertEquals('error', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertEquals(1003, $response['data']['code']);
|
||||
$this->assertEquals('Payload is not valid.', $response['data']['message']);
|
||||
$this->assertEquals('Payload is not valid. Session is required', $response['data']['message']);
|
||||
|
||||
$client->send(\json_encode([
|
||||
'type' => 'unknown',
|
||||
|
||||
@@ -239,7 +239,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('error', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertEquals(1003, $response['data']['code']);
|
||||
$this->assertEquals('Payload is not valid.', $response['data']['message']);
|
||||
$this->assertEquals('Payload is not valid. Session is required', $response['data']['message']);
|
||||
|
||||
$client->send(\json_encode([
|
||||
'type' => 'unknown',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Advisor;
|
||||
|
||||
use Appwrite\Platform\Modules\Advisor\Http\Insights\Get as GetInsight;
|
||||
use Appwrite\Platform\Modules\Advisor\Http\Insights\XList as ListInsights;
|
||||
use Appwrite\Platform\Modules\Advisor\Http\Reports\Delete as DeleteReport;
|
||||
use Appwrite\Platform\Modules\Advisor\Http\Reports\Get as GetReport;
|
||||
use Appwrite\Platform\Modules\Advisor\Http\Reports\XList as ListReports;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Utopia\Platform\Action;
|
||||
|
||||
class AuthTest extends TestCase
|
||||
{
|
||||
#[DataProvider('advisorActionsProvider')]
|
||||
public function testAdvisorApisOnlySupportAdminAndKeyAuth(Action $action): void
|
||||
{
|
||||
/** @var Method $method */
|
||||
$method = $action->getLabels()['sdk'];
|
||||
|
||||
$this->assertSame([AuthType::ADMIN, AuthType::KEY], $method->getAuth());
|
||||
}
|
||||
|
||||
public static function advisorActionsProvider(): array
|
||||
{
|
||||
return [
|
||||
'get report' => [new GetReport()],
|
||||
'list reports' => [new ListReports()],
|
||||
'delete report' => [new DeleteReport()],
|
||||
'get insight' => [new GetInsight()],
|
||||
'list insights' => [new ListInsights()],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1025,4 +1025,105 @@ class MessagingTest extends TestCase
|
||||
$this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event");
|
||||
}
|
||||
}
|
||||
|
||||
public function testFromPayloadPresenceChannels(): void
|
||||
{
|
||||
$presenceId = ID::custom('presence123');
|
||||
|
||||
$result = Realtime::fromPayload(
|
||||
event: 'presences.' . $presenceId . '.upsert',
|
||||
payload: new Document([
|
||||
'$id' => $presenceId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::users()),
|
||||
Permission::delete(Role::users()),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
$this->assertContains('presences', $result['channels']);
|
||||
$this->assertContains('presences.' . $presenceId, $result['channels']);
|
||||
$this->assertContains(Role::any()->toString(), $result['roles']);
|
||||
}
|
||||
|
||||
public function testExtractDeletedPresenceIdReturnsIdForDeleteEvent(): void
|
||||
{
|
||||
$event = [
|
||||
'project' => 'proj',
|
||||
'data' => [
|
||||
'events' => [
|
||||
'presences.abc.delete',
|
||||
'presences.*.delete',
|
||||
'presences.abc',
|
||||
],
|
||||
'payload' => ['$id' => 'abc'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertSame('abc', Realtime::extractDeletedPresenceId($event));
|
||||
}
|
||||
|
||||
public function testExtractDeletedPresenceIdRejectsNonDeleteEvents(): void
|
||||
{
|
||||
$this->assertNull(Realtime::extractDeletedPresenceId([
|
||||
'data' => [
|
||||
'events' => ['presences.abc.upsert'],
|
||||
'payload' => ['$id' => 'abc'],
|
||||
],
|
||||
]));
|
||||
|
||||
// Unrelated resource that happens to end with `.delete` must not trigger.
|
||||
$this->assertNull(Realtime::extractDeletedPresenceId([
|
||||
'data' => [
|
||||
'events' => ['documents.abc.delete'],
|
||||
'payload' => ['$id' => 'abc'],
|
||||
],
|
||||
]));
|
||||
|
||||
// Missing payload ID — the event names look right but we have nothing to remove.
|
||||
$this->assertNull(Realtime::extractDeletedPresenceId([
|
||||
'data' => [
|
||||
'events' => ['presences.abc.delete'],
|
||||
'payload' => [],
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testRemovePresenceFromConnectionsScopedToProject(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
// Two connections in different projects both holding the same presence ID; only
|
||||
// the matching project should be touched.
|
||||
$realtime->connections[1] = [
|
||||
'projectId' => 'proj-a',
|
||||
'presences' => ['p1' => new Document(['$id' => 'p1']), 'p2' => new Document(['$id' => 'p2'])],
|
||||
];
|
||||
$realtime->connections[2] = [
|
||||
'projectId' => 'proj-b',
|
||||
'presences' => ['p1' => new Document(['$id' => 'p1'])],
|
||||
];
|
||||
|
||||
$removed = $realtime->removePresenceFromConnections('proj-a', 'p1');
|
||||
|
||||
$this->assertSame(1, $removed);
|
||||
$this->assertArrayNotHasKey('p1', $realtime->connections[1]['presences']);
|
||||
$this->assertArrayHasKey('p2', $realtime->connections[1]['presences']);
|
||||
$this->assertArrayHasKey('p1', $realtime->connections[2]['presences']);
|
||||
}
|
||||
|
||||
public function testRemovePresenceFromConnectionsNoMatchIsNoOp(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
$realtime->connections[1] = [
|
||||
'projectId' => 'proj-a',
|
||||
'presences' => ['p1' => new Document(['$id' => 'p1'])],
|
||||
];
|
||||
|
||||
$this->assertSame(0, $realtime->removePresenceFromConnections('proj-a', 'missing'));
|
||||
$this->assertSame(0, $realtime->removePresenceFromConnections('', 'p1'));
|
||||
$this->assertSame(0, $realtime->removePresenceFromConnections('proj-a', ''));
|
||||
$this->assertArrayHasKey('p1', $realtime->connections[1]['presences']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user