1
0
Fork 0
mirror of synced 2024-05-19 04:02:34 +12:00

Merge pull request #948 from appwrite/feat-265-realtime

Feat 265 - Realtime Implementation
This commit is contained in:
Torsten Dittmann 2021-09-02 10:28:54 +02:00 committed by GitHub
commit afc1622fc2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 4201 additions and 156 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
/vendor/
/node_modules/
/tests/resources/storage/
/app/sdks/*
/.idea/
.DS_Store
.php_cs.cache

View file

@ -167,6 +167,8 @@ Appwrite's current structure is a combination of both [Monolithic](https://en.wi
│ ├── Migration
│ ├── Network
│ ├── OpenSSL
│ ├── Realtime
│ ├── Resque
│ ├── Specification
│ ├── Task
│ ├── Template

View file

@ -33,6 +33,7 @@ RUN \
git \
zlib-dev \
brotli-dev \
openssl-dev \
yaml-dev \
imagemagick \
imagemagick-dev \
@ -52,7 +53,7 @@ RUN \
git clone --depth 1 --branch $PHP_SWOOLE_VERSION https://github.com/swoole/swoole-src.git && \
cd swoole-src && \
phpize && \
./configure --enable-http2 && \
./configure --enable-sockets --enable-http2 --enable-openssl && \
make && make install && \
cd .. && \
## Imagick Extension
@ -217,6 +218,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/maintenance && \
chmod +x /usr/local/bin/install && \
chmod +x /usr/local/bin/migrate && \
chmod +x /usr/local/bin/realtime && \
chmod +x /usr/local/bin/schedule && \
chmod +x /usr/local/bin/sdks && \
chmod +x /usr/local/bin/ssl && \
@ -246,6 +248,7 @@ RUN if [ "$DEBUG" == "true" ]; then echo "opcache.enable=0" >> /usr/local/etc/ph
RUN echo "opcache.preload_user=www-data" >> /usr/local/etc/php/conf.d/appwrite.ini
RUN echo "opcache.preload=/usr/src/code/app/preload.php" >> /usr/local/etc/php/conf.d/appwrite.ini
RUN echo "opcache.enable_cli=1" >> /usr/local/etc/php/conf.d/appwrite.ini
RUN echo "default_socket_timeout=-1" >> /usr/local/etc/php/conf.d/appwrite.ini
RUN echo "opcache.jit_buffer_size=100M" >> /usr/local/etc/php/conf.d/appwrite.ini
RUN echo "opcache.jit=1235" >> /usr/local/etc/php/conf.d/appwrite.ini

View file

@ -1706,6 +1706,39 @@ $collections = [
],
],
],
Database::SYSTEM_COLLECTION_CONNECTIONS => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_CONNECTIONS,
'$permissions' => ['read' => ['*']],
'name' => 'Realtime Connections',
'structure' => true,
'rules' => [
[
'$collection' => Database::SYSTEM_COLLECTION_CONNECTIONS,
'label' => 'Container',
'key' => 'container',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_CONNECTIONS,
'label' => 'Timestamp',
'key' => 'timestamp',
'type' => Database::SYSTEM_VAR_TYPE_NUMERIC,
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_CONNECTIONS,
'label' => 'Value',
'key' => 'value',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'required' => true,
'array' => false,
],
],
],
Database::SYSTEM_COLLECTION_RESERVED => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_RESERVED,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -222,6 +222,8 @@ App::get('/v1/projects/:projectId/usage')
$requests = [];
$network = [];
$functions = [];
$realtimeConnections = [];
$realtimeMessages = [];
if ($client) {
$start = $period[$range]['start']->format(DateTime::RFC3339);
@ -260,11 +262,34 @@ App::get('/v1/projects/:projectId/usage')
'date' => \strtotime($point['time']),
];
}
// Realtime Connections
$result = $database->query('SELECT sum(value) AS "value" FROM "appwrite_usage_realtime_clients" WHERE time > \''.$start.'\' AND time < \''.$end.'\' AND "metric_type"=\'counter\' AND "project"=\''.$project->getId().'\' GROUP BY time('.$period[$range]['group'].') FILL(null)');
$points = $result->getPoints();
foreach ($points as $point) {
$realtimeConnections[] = [
'value' => (!empty($point['value'])) ? $point['value'] : 0,
'date' => \strtotime($point['time']),
];
}
// Realtime Messages
$result = $database->query('SELECT sum(value) AS "value" FROM "appwrite_usage_realtime_messages" WHERE time > \''.$start.'\' AND time < \''.$end.'\' AND "metric_type"=\'counter\' AND "project"=\''.$project->getId().'\' GROUP BY time('.$period[$range]['group'].') FILL(null)');
$points = $result->getPoints();
foreach ($points as $point) {
$realtimeMessages[] = [
'value' => (!empty($point['value'])) ? $point['value'] : 0,
'date' => \strtotime($point['time']),
];
}
}
} else {
$requests = [];
$network = [];
$functions = [];
$realtimeConnections = [];
$realtimeMessages = [];
}
@ -327,6 +352,18 @@ App::get('/v1/projects/:projectId/usage')
return $item['value'];
}, $functions)),
],
'realtimeConnections' => [
'data' => $realtimeConnections,
'total' => \array_sum(\array_map(function ($item) {
return $item['value'];
}, $realtimeConnections)),
],
'realtimeMessages' => [
'data' => $realtimeMessages,
'total' => \array_sum(\array_map(function ($item) {
return $item['value'];
}, $realtimeMessages)),
],
'collections' => [
'data' => $collections,
'total' => $collectionsTotal,

View file

@ -37,10 +37,12 @@ App::post('/v1/teams')
->inject('response')
->inject('user')
->inject('projectDB')
->action(function ($name, $roles, $response, $user, $projectDB) {
->inject('events')
->action(function ($name, $roles, $response, $user, $projectDB, $events) {
/** @var Appwrite\Utopia\Response $response */
/** @var Appwrite\Database\Document $user */
/** @var Appwrite\Database\Database $projectDB */
/** @var Appwrite\Event\Event $events */
Authorization::disable();
@ -90,6 +92,10 @@ App::post('/v1/teams')
}
}
if (!empty($user->getId())) {
$events->setParam('userId', $user->getId());
}
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($team, Response::MODEL_TEAM)

View file

@ -33,7 +33,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $consoleDB
/** @var Utopia\Locale\Locale $locale */
/** @var bool $mode */
/** @var array $clients */
$domain = $request->getHostname();
$domains = Config::getParam('domains', []);
if (!array_key_exists($domain, $domains)) {
@ -98,7 +98,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $consoleDB
$refDomain = (!empty($protocol) ? $protocol : $request->getProtocol()).'://'.((\in_array($origin, $clients))
? $origin : 'localhost').(!empty($port) ? ':'.$port : '');
$refDomain = (!$route->getLabel('origin', false)) // This route is publicly accessible
? $refDomain
: (!empty($protocol) ? $protocol : $request->getProtocol()).'://'.$origin.(!empty($port) ? ':'.$port : '');
@ -119,7 +119,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $consoleDB
Config::setParam('domainVerification',
($selfDomain->getRegisterable() === $endDomain->getRegisterable()) &&
$endDomain->getRegisterable() !== '');
Config::setParam('cookieDomain', (
$request->getHostname() === 'localhost' ||
$request->getHostname() === 'localhost:'.$request->getPort() ||
@ -189,7 +189,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $consoleDB
&& empty($request->getHeader('x-appwrite-key', ''))) {
throw new Exception($originValidator->getDescription(), 403);
}
/*
* ACL Check
*/
@ -223,7 +223,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $consoleDB
if (!empty($authKey)) { // API Key authentication
// Check if given key match project API keys
$key = $project->search('secret', $authKey, $project->getAttribute('keys', []));
/*
* Try app auth when we have project key and no user
* Mock user to app and grant API key scopes in addition to default app scopes
@ -240,33 +240,22 @@ App::init(function ($utopia, $request, $response, $console, $project, $consoleDB
$role = Auth::USER_ROLE_APP;
$scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', []));
Authorization::setRole('role:'.Auth::USER_ROLE_APP);
Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys.
}
}
if ($user->getId()) {
Authorization::setRole('user:'.$user->getId());
foreach (Auth::getRoles($user) as $authRole) {
Authorization::setRole($authRole);
}
Authorization::setRole('role:'.$role);
\array_map(function ($node) {
if (isset($node['teamId']) && isset($node['roles'])) {
Authorization::setRole('team:'.$node['teamId']);
foreach ($node['roles'] as $nodeRole) { // Set all team roles
Authorization::setRole('team:'.$node['teamId'].'/'.$nodeRole);
}
}
}, $user->getAttribute('memberships', []));
// TDOO Check if user is root
if (!\in_array($scope, $scopes)) {
if (empty($project->getId()) || Database::SYSTEM_COLLECTION_PROJECTS !== $project->getCollection()) { // Check if permission is denied because project is missing
throw new Exception('Project not found', 404);
}
throw new Exception($user->getAttribute('email', 'User').' (role: '.\strtolower($roles[$role]['label']).') missing scope ('.$scope.')', 401);
}
@ -313,12 +302,12 @@ App::error(function ($error, $utopia, $request, $response, $layout, $project) {
if (php_sapi_name() === 'cli') {
Console::error('[Error] Timestamp: '.date('c', time()));
if($route) {
Console::error('[Error] Method: '.$route->getMethod());
Console::error('[Error] URL: '.$route->getPath());
}
Console::error('[Error] Type: '.get_class($error));
Console::error('[Error] Message: '.$error->getMessage());
Console::error('[Error] File: '.$error->getFile());

View file

@ -1,7 +1,9 @@
<?php
use Appwrite\Auth\Auth;
use Appwrite\Database\Document;
use Appwrite\Database\Validator\Authorization;
use Appwrite\Messaging\Adapter\Realtime;
use Utopia\App;
use Utopia\Exception;
use Utopia\Abuse\Abuse;
@ -21,6 +23,7 @@ App::init(function ($utopia, $request, $response, $project, $user, $register, $e
/** @var Appwrite\Event\Event $usage */
/** @var Appwrite\Event\Event $deletes */
/** @var Appwrite\Event\Event $functions */
/** @var PDO $db */
Storage::setDevice('files', new Local(APP_STORAGE_UPLOADS.'/app-'.$project->getId()));
Storage::setDevice('functions', new Local(APP_STORAGE_FUNCTIONS.'/app-'.$project->getId()));
@ -111,7 +114,6 @@ App::init(function ($utopia, $request, $response, $project, $user, $register, $e
}, ['utopia', 'request', 'response', 'project', 'user', 'register', 'events', 'audits', 'usage', 'deletes', 'db'], 'api');
App::init(function ($utopia, $request, $response, $project, $user) {
/** @var Utopia\App $utopia */
/** @var Utopia\Swoole\Request $request */
@ -181,7 +183,6 @@ App::shutdown(function ($utopia, $request, $response, $project, $events, $audits
/** @var Appwrite\Event\Event $audits */
/** @var Appwrite\Event\Event $usage */
/** @var Appwrite\Event\Event $deletes */
/** @var Appwrite\Event\Event $functions */
/** @var bool $mode */
if (!empty($events->getParam('event'))) {
@ -201,6 +202,23 @@ App::shutdown(function ($utopia, $request, $response, $project, $events, $audits
->setQueue('v1-functions')
->setClass('FunctionsV1')
->trigger();
if ($project->getId() !== 'console') {
$payload = new Document($response->getPayload());
$target = Realtime::fromPayload($events->getParam('event'), $payload);
Realtime::send(
$project->getId(),
$response->getPayload(),
$events->getParam('event'),
$target['channels'],
$target['roles'],
[
'permissionsChanged' => $target['permissionsChanged'],
'userId' => $events->getParam('userId')
]
);
}
}
if (!empty($audits->getParam('event'))) {

View file

@ -26,6 +26,7 @@ use Appwrite\Database\Adapter\Redis as RedisAdapter;
use Appwrite\Database\Document;
use Appwrite\Database\Validator\Authorization;
use Appwrite\Event\Event;
use Appwrite\Event\Realtime;
use Appwrite\OpenSSL\OpenSSL;
use Utopia\App;
use Utopia\View;
@ -70,6 +71,7 @@ const DELETE_TYPE_EXECUTIONS = 'executions';
const DELETE_TYPE_AUDIT = 'audit';
const DELETE_TYPE_ABUSE = 'abuse';
const DELETE_TYPE_CERTIFICATES = 'certificates';
const DELETE_TYPE_REALTIME = 'realtime';
// Mail Types
const MAIL_TYPE_VERIFICATION = 'verification';
const MAIL_TYPE_MAGIC_SESSION = 'magicSession';
@ -421,10 +423,10 @@ App::setResource('user', function($mode, $project, $console, $request, $response
$request->getCookie(Auth::$cookieName.'_legacy', '')));// Get fallback session from old clients (no SameSite support)
// Get fallback session from clients who block 3rd-party cookies
$response->addHeader('X-Debug-Fallback', 'false');
if($response) $response->addHeader('X-Debug-Fallback', 'false');
if(empty($session['id']) && empty($session['secret'])) {
$response->addHeader('X-Debug-Fallback', 'true');
if($response) $response->addHeader('X-Debug-Fallback', 'true');
$fallback = $request->getHeader('x-fallback-cookies', '');
$fallback = \json_decode($fallback, true);
$session = Auth::decodeSession(((isset($fallback[Auth::$cookieName])) ? $fallback[Auth::$cookieName] : ''));
@ -435,8 +437,7 @@ App::setResource('user', function($mode, $project, $console, $request, $response
if (APP_MODE_ADMIN !== $mode) {
$user = $projectDB->getDocument(Auth::$unique);
}
else {
} else {
$user = $consoleDB->getDocument(Auth::$unique);
$user
@ -468,7 +469,7 @@ App::setResource('user', function($mode, $project, $console, $request, $response
} catch (JWTException $error) {
throw new Exception('Failed to verify JWT. '.$error->getMessage(), 401);
}
$jwtUserId = $payload['userId'] ?? '';
$jwtSessionId = $payload['sessionId'] ?? '';

View file

@ -32,6 +32,7 @@ foreach ([
realpath(__DIR__ . '/../vendor/psr/log'),
realpath(__DIR__ . '/../vendor/matomo'),
realpath(__DIR__ . '/../vendor/symfony'),
realpath(__DIR__ . '/../vendor/utopia-php/websocket'), // TODO: remove workerman autoload
] as $key => $value) {
if($value !== false) {
$preloader->ignore($value);

581
app/realtime.php Normal file
View file

@ -0,0 +1,581 @@
<?php
use Appwrite\Auth\Auth;
use Appwrite\Database\Adapter\Redis as RedisAdapter;
use Appwrite\Database\Adapter\MySQL as MySQLAdapter;
use Appwrite\Database\Database;
use Appwrite\Database\Validator\Authorization;
use Appwrite\Event\Event;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Network\Validator\Origin;
use Appwrite\Utopia\Response;
use Swoole\Http\Request as SwooleRequest;
use Swoole\Http\Response as SwooleResponse;
use Swoole\Runtime;
use Swoole\Table;
use Swoole\Timer;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit;
use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Swoole\Request;
use Utopia\WebSocket\Server;
use Utopia\WebSocket\Adapter;
require_once __DIR__ . '/init.php';
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
$realtime = new Realtime();
/**
* Table for statistics across all workers.
*/
$stats = new Table(4096, 1);
$stats->column('projectId', Table::TYPE_STRING, 64);
$stats->column('teamId', Table::TYPE_STRING, 64);
$stats->column('connections', Table::TYPE_INT);
$stats->column('connectionsTotal', Table::TYPE_INT);
$stats->column('messages', Table::TYPE_INT);
$stats->create();
$containerId = uniqid();
$documentId = null;
$adapter = new Adapter\Swoole(port: App::getEnv('PORT', 80));
$adapter->setPackageMaxLength(64000); // Default maximum Package Size (64kb)
$server = new Server($adapter);
$server->onStart(function () use ($stats, $register, $containerId, &$documentId) {
Console::success('Server started succefully');
$getConsoleDb = function () use ($register) {
$db = $register->get('dbPool')->get();
$cache = $register->get('redisPool')->get();
$consoleDb = new Database();
$consoleDb->setAdapter(new RedisAdapter(new MySQLAdapter($db, $cache), $cache));
$consoleDb->setNamespace('app_console');
$consoleDb->setMocks(Config::getParam('collections', []));
return [
$consoleDb,
function () use ($register, $db, $cache) {
$register->get('dbPool')->put($db);
$register->get('redisPool')->put($cache);
}
];
};
/**
* Create document for this worker to share stats across Containers.
*/
go(function () use ($getConsoleDb, $containerId, &$documentId) {
try {
[$consoleDb, $returnConsoleDb] = call_user_func($getConsoleDb);
$document = [
'$collection' => Database::SYSTEM_COLLECTION_CONNECTIONS,
'$permissions' => [
'read' => ['*'],
'write' => ['*'],
],
'container' => $containerId,
'timestamp' => time(),
'value' => '{}'
];
Authorization::disable();
$document = $consoleDb->createDocument($document);
Authorization::enable();
$documentId = $document->getId();
} catch (\Throwable $th) {
Console::error('[Error] Type: ' . get_class($th));
Console::error('[Error] Message: ' . $th->getMessage());
Console::error('[Error] File: ' . $th->getFile());
Console::error('[Error] Line: ' . $th->getLine());
} finally {
call_user_func($returnConsoleDb);
}
});
/**
* Save current connections to the Database every 5 seconds.
*/
Timer::tick(5000, function () use ($stats, $getConsoleDb, $containerId, &$documentId) {
[$consoleDb, $returnConsoleDb] = call_user_func($getConsoleDb);
foreach ($stats as $projectId => $value) {
if (empty($value['connections']) && empty($value['messages'])) {
continue;
}
$connections = $value['connections'];
$messages = $value['messages'];
$usage = new Event('v1-usage', 'UsageV1');
$usage
->setParam('projectId', $projectId)
->setParam('realtimeConnections', $connections)
->setParam('realtimeMessages', $messages)
->setParam('networkRequestSize', 0)
->setParam('networkResponseSize', 0);
$stats->set($projectId, [
'messages' => 0,
'connections' => 0
]);
if (App::getEnv('_APP_USAGE_STATS', 'enabled') == 'enabled') {
$usage->trigger();
}
}
$payload = [];
foreach ($stats as $projectId => $value) {
if (!empty($value['connectionsTotal'])) {
$payload[$projectId] = $value['connectionsTotal'];
}
}
if (empty($payload)) {
return;
}
$document = [
'$id' => $documentId,
'$collection' => Database::SYSTEM_COLLECTION_CONNECTIONS,
'$permissions' => [
'read' => ['*'],
'write' => ['*'],
],
'container' => $containerId,
'timestamp' => time(),
'value' => json_encode($payload)
];
try {
$document = $consoleDb->updateDocument($document);
} catch (\Throwable $th) {
Console::error('[Error] Type: ' . get_class($th));
Console::error('[Error] Message: ' . $th->getMessage());
Console::error('[Error] File: ' . $th->getFile());
Console::error('[Error] Line: ' . $th->getLine());
} finally {
call_user_func($returnConsoleDb);
}
});
});
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) {
Console::success('Worker ' . $workerId . ' started succefully');
$attempts = 0;
$start = time();
Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
/**
* Sending current connections to project channels on the console project every 5 seconds.
*/
if ($realtime->hasSubscriber('console', 'role:member', 'project')) {
$db = $register->get('dbPool')->get();
$cache = $register->get('redisPool')->get();
$consoleDb = new Database();
$consoleDb->setAdapter(new RedisAdapter(new MySQLAdapter($db, $cache), $cache));
$consoleDb->setNamespace('app_console');
$consoleDb->setMocks(Config::getParam('collections', []));
$payload = [];
$list = $consoleDb->getCollection([
'filters' => [
'$collection=' . Database::SYSTEM_COLLECTION_CONNECTIONS,
'timestamp>' . (time() - 15)
],
]);
/**
* Aggregate stats across containers.
*/
foreach ($list as $document) {
foreach (json_decode($document->getAttribute('value')) as $projectId => $value) {
if (array_key_exists($projectId, $payload)) {
$payload[$projectId] += $value;
} else {
$payload[$projectId] = $value;
}
}
}
foreach ($stats as $projectId => $value) {
if (!array_key_exists($projectId, $payload)) {
continue;
}
$event = [
'project' => 'console',
'roles' => ['team:' . $value['teamId']],
'data' => [
'event' => 'stats.connections',
'channels' => ['project'],
'timestamp' => time(),
'payload' => [
$projectId => $payload[$projectId]
]
]
];
$server->send($realtime->getSubscribers($event), json_encode([
'type' => 'event',
'data' => $event['data']
]));
}
$register->get('dbPool')->put($db);
$register->get('redisPool')->put($cache);
}
/**
* Sending test message for SDK E2E tests every 5 seconds.
*/
if ($realtime->hasSubscriber('console', 'role:guest', 'tests')) {
$payload = ['response' => 'WS:/v1/realtime:passed'];
$event = [
'project' => 'console',
'roles' => ['role:guest'],
'data' => [
'event' => 'test.event',
'channels' => ['tests'],
'timestamp' => time(),
'payload' => $payload
]
];
$server->send($realtime->getSubscribers($event), json_encode([
'type' => 'event',
'data' => $event['data']
]));
}
});
while ($attempts < 300) {
try {
if ($attempts > 0) {
Console::error('Pub/sub connection lost (lasted ' . (time() - $start) . ' seconds, worker: ' . $workerId . ').
Attempting restart in 5 seconds (attempt #' . $attempts . ')');
sleep(5); // 5 sec delay between connection attempts
}
$start = time();
/** @var Redis $redis */
$redis = $register->get('redisPool')->get();
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);
if ($redis->ping(true)) {
$attempts = 0;
Console::success('Pub/sub connection established (worker: ' . $workerId . ')');
} else {
Console::error('Pub/sub failed (worker: ' . $workerId . ')');
}
$redis->subscribe(['realtime'], function (Redis $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) {
$event = json_decode($payload, true);
if ($event['permissionsChanged'] && isset($event['userId'])) {
$projectId = $event['project'];
$userId = $event['userId'];
if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) {
$connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId]));
} else {
return;
}
$db = $register->get('dbPool')->get();
$cache = $register->get('redisPool')->get();
$projectDB = new Database();
$projectDB->setAdapter(new RedisAdapter(new MySQLAdapter($db, $cache), $cache));
$projectDB->setNamespace('app_' . $projectId);
$projectDB->setMocks(Config::getParam('collections', []));
$user = $projectDB->getDocument($userId);
$roles = Auth::getRoles($user);
$realtime->subscribe($projectId, $connection, $roles, $realtime->connections[$connection]['channels']);
$register->get('dbPool')->put($db);
$register->get('redisPool')->put($cache);
}
$receivers = $realtime->getSubscribers($event);
if (App::isDevelopment() && !empty($receivers)) {
Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers));
Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers));
Console::log("[Debug][Worker {$workerId}] Event: " . $payload);
}
$server->send(
$receivers,
json_encode([
'type' => 'event',
'data' => $event['data']
])
);
if (($num = count($receivers)) > 0) {
$stats->incr($event['project'], 'messages', $num);
}
});
} catch (\Throwable $th) {
Console::error('Pub/sub error: ' . $th->getMessage());
$register->get('redisPool')->put($redis);
$attempts++;
continue;
}
$attempts++;
}
Console::error('Failed to restart pub/sub...');
});
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
$app = new App('UTC');
$request = new Request($request);
$response = new Response(new SwooleResponse());
/** @var PDO $db */
$db = $register->get('dbPool')->get();
/** @var Redis $redis */
$redis = $register->get('redisPool')->get();
Console::info("Connection open (user: {$connection})");
App::setResource('db', function () use (&$db) {
return $db;
});
App::setResource('cache', function () use (&$redis) {
return $redis;
});
App::setResource('request', function () use ($request) {
return $request;
});
App::setResource('response', function () use ($response) {
return $response;
});
try {
/** @var \Appwrite\Database\Document $user */
$user = $app->getResource('user');
/** @var \Appwrite\Database\Document $project */
$project = $app->getResource('project');
/** @var \Appwrite\Database\Document $console */
$console = $app->getResource('console');
/*
* Project Check
*/
if (empty($project->getId())) {
throw new Exception('Missing or unknown project ID', 1008);
}
/*
* Abuse Check
*
* Abuse limits are connecting 128 times per minute and ip address.
*/
$timeLimit = new TimeLimit('url:{url},ip:{ip}', 128, 60, $db);
$timeLimit
->setNamespace('app_' . $project->getId())
->setParam('{ip}', $request->getIP())
->setParam('{url}', $request->getURI());
$abuse = new Abuse($timeLimit);
if ($abuse->check() && App::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled') {
throw new Exception('Too many requests', 1013);
}
/*
* Validate Client Domain - Check to avoid CSRF attack.
* Adding Appwrite API domains to allow XDOMAIN communication.
* Skip this check for non-web platforms which are not required to send an origin header.
*/
$origin = $request->getOrigin();
$originValidator = new Origin(\array_merge($project->getAttribute('platforms', []), $console->getAttribute('platforms', [])));
if (!$originValidator->isValid($origin) && $project->getId() !== 'console') {
throw new Exception($originValidator->getDescription(), 1008);
}
$roles = Auth::getRoles($user);
$channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId());
/**
* Channels Check
*/
if (empty($channels)) {
throw new Exception('Missing channels', 1008);
}
$realtime->subscribe($project->getId(), $connection, $roles, $channels);
$user = empty($user->getId()) ? null : $response->output($user, Response::MODEL_USER);
$server->send([$connection], json_encode([
'type' => 'connected',
'data' => [
'channels' => array_keys($channels),
'user' => $user
]
]));
$stats->set($project->getId(), [
'projectId' => $project->getId(),
'teamId' => $project->getAttribute('teamId')
]);
$stats->incr($project->getId(), 'connections');
$stats->incr($project->getId(), 'connectionsTotal');
} catch (\Throwable $th) {
$response = [
'type' => 'error',
'data' => [
'code' => $th->getCode(),
'message' => $th->getMessage()
]
];
$server->send([$connection], json_encode($response));
$server->close($connection, $th->getCode());
if (App::isDevelopment()) {
Console::error('[Error] Connection Error');
Console::error('[Error] Code: ' . $response['data']['code']);
Console::error('[Error] Message: ' . $response['data']['message']);
}
if ($th instanceof PDOException) {
$db = null;
}
} finally {
/**
* Put used PDO and Redis Connections back into their pools.
*/
$register->get('dbPool')->put($db);
$register->get('redisPool')->put($redis);
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
try {
$response = new Response(new SwooleResponse());
$db = $register->get('dbPool')->get();
$cache = $register->get('redisPool')->get();
$projectDB = new Database();
$projectDB->setAdapter(new RedisAdapter(new MySQLAdapter($db, $cache), $cache));
$projectDB->setNamespace('app_' . $realtime->connections[$connection]['projectId']);
$projectDB->setMocks(Config::getParam('collections', []));
/*
* Abuse Check
*
* Abuse limits are sending 32 times per minute and connection.
*/
$timeLimit = new TimeLimit('url:{url},conection:{connection}', 32, 60, $db);
$timeLimit
->setNamespace('app_' . $realtime->connections[$connection]['projectId'])
->setParam('{connection}', $connection)
->setParam('{container}', $containerId);
$abuse = new Abuse($timeLimit);
if ($abuse->check() && App::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled') {
throw new Exception('Too many messages', 1013);
}
$message = json_decode($message, true);
if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) {
throw new Exception('Message format is not valid.', 1003);
}
switch ($message['type']) {
/**
* This type is used to authenticate.
*/
case 'authentication':
if (!array_key_exists('session', $message['data'])) {
throw new Exception('Payload is not valid.', 1003);
}
$session = Auth::decodeSession($message['data']['session']);
Auth::$unique = $session['id'];
Auth::$secret = $session['secret'];
$user = $projectDB->getDocument(Auth::$unique);
if (
empty($user->getId()) // Check a document has been found in the DB
|| Database::SYSTEM_COLLECTION_USERS !== $user->getCollection() // Validate returned document is really a user document
|| !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret) // Validate user has valid login token
) {
// cookie not valid
throw new Exception('Session is not valid.', 1003);
}
$roles = Auth::getRoles($user);
$channels = Realtime::convertChannels(array_flip($realtime->connections[$connection]['channels']), $user->getId());
$realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels);
$user = $response->output($user, Response::MODEL_USER);
$server->send([$connection], json_encode([
'type' => 'response',
'data' => [
'to' => 'authentication',
'success' => true,
'user' => $user
]
]));
break;
default:
throw new Exception('Message type is not valid.', 1003);
break;
}
} catch (\Throwable $th) {
$response = [
'type' => 'error',
'data' => [
'code' => $th->getCode(),
'message' => $th->getMessage()
]
];
$server->send([$connection], json_encode($response));
if ($th->getCode() === 1008) {
$server->close($connection, $th->getCode());
}
} finally {
$register->get('dbPool')->put($db);
$register->get('redisPool')->put($cache);
}
});
$server->onClose(function (int $connection) use ($realtime, $stats) {
if (array_key_exists($connection, $realtime->connections)) {
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
}
$realtime->unsubscribe($connection);
Console::info('Connection close: ' . $connection);
});
$server->start();

View file

@ -39,6 +39,14 @@ $cli
]);
}
function notifyDeleteConnections()
{
Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [
'type' => DELETE_TYPE_REALTIME,
'timestamp' => time() - 60
]);
}
// # of days in seconds (1 day = 86400s)
$interval = (int) App::getEnv('_APP_MAINTENANCE_INTERVAL', '86400');
$executionLogsRetention = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', '1209600');
@ -51,5 +59,6 @@ $cli
notifyDeleteExecutionLogs($executionLogsRetention);
notifyDeleteAbuseLogs($abuseLogsRetention);
notifyDeleteAuditLogs($auditLogRetention);
notifyDeleteConnections();
}, $interval);
});

View file

@ -100,7 +100,7 @@
<input type="hidden" id="collection-read" name="read" required data-cast-to="json" value="<?php echo htmlentities(json_encode([])); ?>" />
<input type="hidden" id="collection-write" name="write" required data-cast-to="json" value="<?php echo htmlentities(json_encode([])); ?>" />
<input type="hidden" id="collection-rules" name="rules" required data-cast-to="json" value="{}" />
<hr />
<button type="submit">Create</button> &nbsp; <button data-ui-modal-close="" type="button" class="reverse">Cancel</button>

View file

@ -4,7 +4,7 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);
?>
<div class="cover margin-bottom-small">
<div class="zone xl margin-bottom-xl margin-top-small">
<div class="zone xxl margin-bottom-xl margin-top-small">
<h1 class="margin-bottom-small"
data-service="projects.get"
data-event="load,project.update,projects.createPlatform,projects.updatePlatform,projects.deletePlatform"
@ -39,7 +39,8 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);
data-event="submit"
data-name="usage"
data-param-project-id="{{router.params.project}}"
data-param-range="24h">
data-param-range="24h"
data-scope="console">
<button class="tick">24h</button>
</form>
@ -53,7 +54,8 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);
data-service="projects.getUsage"
data-event="submit"
data-name="usage"
data-param-project-id="{{router.params.project}}">
data-param-project-id="{{router.params.project}}"
data-scope="console">
<button class="tick">30d</button>
</form>
@ -68,7 +70,8 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);
data-event="submit"
data-name="usage"
data-param-project-id="{{router.params.project}}"
data-param-range="90d">
data-param-range="90d"
data-scope="console">
<button class="tick">90d</button>
</form>
@ -83,8 +86,7 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);
data-name="usage"
data-param-project-id="{{router.params.project}}"
data-param-range="30d">
<?php if (!$graph && $usageStatsEnabled): ?>
<?php if (!$graph && $usageStatsEnabled): ?>
<div class="box dashboard">
<div class="row responsive">
<div class="col span-9">
@ -94,50 +96,46 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);
<div class="chart-metric">
<div class="value margin-bottom-small"><span class="sum" data-ls-bind="{{usage.requests.total|statsTotal}}">N/A</span></div>
<div class="metric margin-bottom-small">Requests <span class="tooltip" data-tooltip="Total number of API requests"><i class="icon-info-circled"></i></span></div>
<div class="unit margin-start-no margin-bottom-small">Requests</div>
</div>
</div>
<div class="col span-3">
<div class="value margin-bottom-small">
<span class="sum" data-ls-bind="{{usage.network.total|humanFileSize}}" data-default="0">0</span>
<span data-ls-bind="{{usage.network.total|humanFileUnit}}" class="text-size-small unit"></span>
</div>
<div class="metric margin-bottom-small">Bandwidth</div>
<div class="margin-top-large value small">
<b class="text-size-small sum small" data-ls-bind="{{usage.functions.total|statsTotal}}" data-default="0"></b>
<br />
<b>Func. Executions</b>
<span class="sum" data-ls-bind="{{realtime.current|accessProject}}" data-default="0">0</span>
</div>
<div class="unit margin-start-no margin-bottom-small">Connections</div>
<div class="chart-bar margin-top-small margin-bottom-small" data-ls-attrs="data-history={{realtime.history|accessProject}}" data-forms-chart-bars="{{realtime.history|accessProject}}"></div>
<div class="text-fade-dark text-size-small">Activity last 60 seconds</div>
</div>
</div>
</div>
<?php endif; ?>
<div class="box dashboard">
<div class="row responsive">
<div class="col span-3">
<div class="value"><span class="sum" data-ls-bind="{{usage.documents.total|statsTotal}}" data-default="0">0</span></div>
<div class="margin-top-small"><b class="text-size-small unit">Documents</b></div>
</div>
<div class="col span-3">
<div class="value">
<span class="sum" data-ls-bind="{{usage.storage.total|humanFileSize}}" data-default="0">0</span>
<span data-ls-bind="{{usage.storage.total|humanFileUnit}}" class="text-size-small unit"></span>
<?php endif; ?>
<div class="box dashboard">
<div class="row responsive">
<div class="col span-3">
<div class="value"><span class="sum" data-ls-bind="{{usage.documents.total|statsTotal}}" data-default="0">0</span></div>
<div class="margin-top-small"><b class="text-size-small unit">Documents</b></div>
</div>
<div class="col span-3">
<div class="value">
<span class="sum" data-ls-bind="{{usage.storage.total|humanFileSize}}" data-default="0">0</span>
<span data-ls-bind="{{usage.storage.total|humanFileUnit}}" class="text-size-small unit"></span>
</div>
<div class="margin-top-small"><b class="text-size-small unit">Storage</b></div>
</div>
<div class="col span-3">
<div class="value"><span class="sum" data-ls-bind="{{usage.users.total}}" data-default="0">0</span></div>
<div class="margin-top-small"><b class="text-size-small unit">Users</b></div>
</div>
<div class="col span-3">
<div class="value"><span class="sum" data-ls-bind="{{usage.functions.total|statsTotal}}" data-default="0">0</span></div>
<div class="margin-top-small"><b class="text-size-small unit">Executions</b></div>
</div>
<div class="margin-top-small"><b class="text-size-small unit">Storage</b></div>
</div>
<div class="col span-3">
<div class="value"><span class="sum" data-ls-bind="{{usage.users.total}}" data-default="0">0</span></div>
<div class="margin-top-small"><b class="text-size-small unit">Users</b></div>
</div>
<div class="col span-3">
<div class="value"><span class="sum" data-ls-bind="{{usage.tasks.total}}" data-default="0">0</span></div>
<div class="margin-top-small"><b class="text-size-small unit">Tasks</b></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="zone xl margin-top-xl clear" data-ls-if="({{console-project}})">

View file

@ -17,8 +17,8 @@ services:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.constraints=Label(`traefik.constraint-label-stack`,`appwrite`)
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.appwrite_web.address=:80
- --entrypoints.appwrite_websecure.address=:443
restart: unless-stopped
ports:
- <?php echo $httpPort; ?>:80
@ -42,9 +42,17 @@ services:
labels:
- traefik.enable=true
- traefik.constraint-label-stack=appwrite
- traefik.http.routers.appwrite.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite-secure.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite-secure.tls=true
- traefik.docker.network=appwrite
- traefik.http.services.appwrite_api.loadbalancer.server.port=80
#http
- traefik.http.routers.appwrite_api_http.entrypoints=appwrite_web
- traefik.http.routers.appwrite_api_http.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite_api_http.service=appwrite_api
# https
- traefik.http.routers.appwrite_api_https.entrypoints=appwrite_websecure
- traefik.http.routers.appwrite_api_https.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite_api_https.service=appwrite_api
- traefik.http.routers.appwrite_api_https.tls=true
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@ -99,6 +107,43 @@ services:
- _APP_FUNCTIONS_MEMORY_SWAP
- _APP_FUNCTIONS_RUNTIMES
appwrite-realtime:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
entrypoint: realtime
container_name: appwrite-realtime
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=appwrite"
- "traefik.http.services.appwrite_realtime.loadbalancer.server.port=80"
#ws
- traefik.http.routers.appwrite_realtime_ws.entrypoints=appwrite_web
- traefik.http.routers.appwrite_realtime_ws.rule=PathPrefix(`/v1/realtime`)
- traefik.http.routers.appwrite_realtime_ws.service=appwrite_realtime
# wss
- traefik.http.routers.appwrite_realtime_wss.entrypoints=appwrite_websecure
- traefik.http.routers.appwrite_realtime_wss.rule=PathPrefix(`/v1/realtime`)
- traefik.http.routers.appwrite_realtime_wss.service=appwrite_realtime
- traefik.http.routers.appwrite_realtime_wss.tls=true
- traefik.http.routers.appwrite_realtime_wss.tls.certresolver=dns
networks:
- appwrite
depends_on:
- redis
environment:
- _APP_ENV
- _APP_OPTIONS_ABUSE
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_USAGE_STATS
appwrite-worker-usage:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
entrypoint: worker-usage

View file

@ -70,6 +70,10 @@ class DeletesV1 extends Worker
$this->deleteAbuseLogs($this->args['timestamp']);
break;
case DELETE_TYPE_REALTIME:
$this->deleteRealtimeUsage($this->args['timestamp']);
break;
case DELETE_TYPE_CERTIFICATES:
$document = new Document($this->args['document']);
$this->deleteCertificates($document);
@ -204,6 +208,19 @@ class DeletesV1 extends Worker
});
}
protected function deleteRealtimeUsage($timestamp)
{
if (!($consoleDB = $this->getConsoleDB())) {
throw new Exception('Failed to get consoleDb.');
}
// Delete Dead Realtime Logs
$this->deleteByGroup([
'$collection='.Database::SYSTEM_COLLECTION_REALTIME_CONNECTIONS,
'timestamp<'.$timestamp
], $consoleDB);
}
protected function deleteFunction(Document $document, $projectId)
{
$projectDB = $this->getProjectDB($projectId);

View file

@ -6,6 +6,7 @@ use Appwrite\Database\Adapter\MySQL as MySQLAdapter;
use Appwrite\Database\Adapter\Redis as RedisAdapter;
use Appwrite\Database\Validator\Authorization;
use Appwrite\Event\Event;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Resque\Worker;
use Appwrite\Utopia\Response\Model\Execution;
use Cron\CronExpression;
@ -531,6 +532,16 @@ class FunctionsV1 extends Worker
$executionUpdate->trigger();
$target = Realtime::fromPayload('functions.executions.update', $execution);
Realtime::send(
$projectId,
$execution->getArrayCopy(),
'functions.executions.update',
$target['channels'],
$target['roles']
);
$usage = new Event('v1-usage', 'UsageV1');
$usage
@ -540,7 +551,8 @@ class FunctionsV1 extends Worker
->setParam('functionStatus', $functionStatus)
->setParam('functionExecutionTime', $executionTime * 1000) // ms
->setParam('networkRequestSize', 0)
->setParam('networkResponseSize', 0);
->setParam('networkResponseSize', 0)
;
if (App::getEnv('_APP_USAGE_STATS', 'enabled') == 'enabled') {
$usage->trigger();

View file

@ -37,7 +37,10 @@ class UsageV1 extends Worker
$functionExecutionTime = $this->args['functionExecutionTime'] ?? 0;
$functionStatus = $this->args['functionStatus'] ?? '';
$tags = ",project={$projectId},version=" . App::getEnv('_APP_VERSION', 'UNKNOWN');
$realtimeConnections = $this->args['realtimeConnections'] ?? 0;
$realtimeMessages = $this->args['realtimeMessages'] ?? 0;
$tags = ",project={$projectId},version=".App::getEnv('_APP_VERSION', 'UNKNOWN');
// the global namespace is prepended to every key (optional)
$statsd->setNamespace('appwrite.usage');
@ -51,9 +54,17 @@ class UsageV1 extends Worker
$statsd->count('executions.time' . $tags . ',functionId=' . $functionId, $functionExecutionTime);
}
$statsd->count('network.inbound' . $tags, $networkRequestSize);
$statsd->count('network.outbound' . $tags, $networkResponseSize);
$statsd->count('network.all' . $tags, $networkRequestSize + $networkResponseSize);
if($realtimeConnections >= 1) {
$statsd->count('realtime.clients'.$tags, $realtimeConnections);
}
if($realtimeMessages >= 1) {
$statsd->count('realtime.messages'.$tags, $realtimeMessages);
}
$statsd->count('network.inbound'.$tags, $networkRequestSize);
$statsd->count('network.outbound'.$tags, $networkResponseSize);
$statsd->count('network.all'.$tags, $networkRequestSize + $networkResponseSize);
if ($storage >= 1) {
$statsd->count('storage.all' . $tags, $storage);

3
bin/realtime Normal file
View file

@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/realtime.php $@

View file

@ -51,6 +51,7 @@
"utopia-php/domains": "1.1.*",
"utopia-php/swoole": "0.2.*",
"utopia-php/storage": "0.5.*",
"utopia-php/websocket": "0.0.*",
"utopia-php/image": "0.5.*",
"resque/php-resque": "1.3.6",
"matomo/device-detector": "4.2.3",
@ -64,8 +65,9 @@
},
"require-dev": {
"appwrite/sdk-generator": "0.12.1",
"swoole/ide-helper": "4.6.7",
"phpunit/phpunit": "9.5.6",
"swoole/ide-helper": "4.6.7",
"textalk/websocket": "1.5.2",
"vimeo/psalm": "4.7.2"
},
"provide": {

109
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0a782184d016e458ff18f20a2c49ccfb",
"content-hash": "cbfa98da20b9061eeec78a86ef2a8bed",
"packages": [
{
"name": "adhocore/jwt",
@ -2230,6 +2230,64 @@
},
"time": "2021-02-04T14:14:49+00:00"
},
{
"name": "utopia-php/websocket",
"version": "0.0.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/websocket.git",
"reference": "808317ef4ea0683c2c82dee5d543b1c8378e2e1b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/websocket/zipball/808317ef4ea0683c2c82dee5d543b1c8378e2e1b",
"reference": "808317ef4ea0683c2c82dee5d543b1c8378e2e1b",
"shasum": ""
},
"require": {
"php": ">=8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5.5",
"swoole/ide-helper": "4.6.6",
"textalk/websocket": "1.5.2",
"vimeo/psalm": "^4.8.1",
"workerman/workerman": "^4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Utopia\\WebSocket\\": "src/WebSocket"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Eldad Fux",
"email": "eldad@appwrite.io"
},
{
"name": "Torsten Dittmann",
"email": "torsten@appwrite.io"
}
],
"description": "A simple abstraction for WebSocket servers.",
"keywords": [
"framework",
"php",
"upf",
"utopia",
"websocket"
],
"support": {
"issues": "https://github.com/utopia-php/websocket/issues",
"source": "https://github.com/utopia-php/websocket/tree/0.0.1"
},
"time": "2021-07-11T13:09:44+00:00"
},
{
"name": "webmozart/assert",
"version": "1.10.0",
@ -5835,6 +5893,55 @@
],
"time": "2021-08-26T08:00:08+00:00"
},
{
"name": "textalk/websocket",
"version": "1.5.2",
"source": {
"type": "git",
"url": "https://github.com/Textalk/websocket-php.git",
"reference": "b93249453806a2dd46495de46d76fcbcb0d8dee8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Textalk/websocket-php/zipball/b93249453806a2dd46495de46d76fcbcb0d8dee8",
"reference": "b93249453806a2dd46495de46d76fcbcb0d8dee8",
"shasum": ""
},
"require": {
"php": "^7.2 | ^8.0",
"psr/log": "^1.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.0",
"phpunit/phpunit": "^8.0|^9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-4": {
"WebSocket\\": "lib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"ISC"
],
"authors": [
{
"name": "Fredrik Liljegren"
},
{
"name": "Sören Jensen",
"email": "soren@abicart.se"
}
],
"description": "WebSocket client and server",
"support": {
"issues": "https://github.com/Textalk/websocket-php/issues",
"source": "https://github.com/Textalk/websocket-php/tree/1.5.2"
},
"time": "2021-02-12T15:39:23+00:00"
},
{
"name": "theseer/tokenizer",
"version": "1.2.1",

View file

@ -17,8 +17,8 @@ services:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.constraints=Label(`traefik.constraint-label-stack`,`appwrite`)
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.appwrite_web.address=:80
- --entrypoints.appwrite_websecure.address=:443
- --accesslog=true
ports:
- 80:80
@ -47,11 +47,19 @@ services:
networks:
- appwrite
labels:
- traefik.enable=true
- traefik.constraint-label-stack=appwrite
- traefik.http.routers.appwrite.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite-secure.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite-secure.tls=true
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=appwrite"
- "traefik.http.services.appwrite_api.loadbalancer.server.port=80"
#http
- traefik.http.routers.appwrite_api_http.entrypoints=appwrite_web
- traefik.http.routers.appwrite_api_http.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite_api_http.service=appwrite_api
# https
- traefik.http.routers.appwrite_api_https.entrypoints=appwrite_websecure
- traefik.http.routers.appwrite_api_https.rule=PathPrefix(`/`)
- traefik.http.routers.appwrite_api_https.service=appwrite_api
- traefik.http.routers.appwrite_api_https.tls=true
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- appwrite-uploads:/storage/uploads:rw
@ -122,6 +130,49 @@ services:
- _APP_FUNCTIONS_MEMORY_SWAP
- _APP_FUNCTIONS_RUNTIMES
appwrite-realtime:
entrypoint: realtime
container_name: appwrite-realtime
build:
context: .
restart: unless-stopped
ports:
- 9505:80
labels:
- "traefik.enable=true"
- "traefik.constraint-label-stack=appwrite"
- "traefik.docker.network=appwrite"
- "traefik.http.services.appwrite_realtime.loadbalancer.server.port=80"
#ws
- traefik.http.routers.appwrite_realtime_ws.entrypoints=appwrite_web
- traefik.http.routers.appwrite_realtime_ws.rule=PathPrefix(`/v1/realtime`)
- traefik.http.routers.appwrite_realtime_ws.service=appwrite_realtime
# wss
- traefik.http.routers.appwrite_realtime_wss.entrypoints=appwrite_websecure
- traefik.http.routers.appwrite_realtime_wss.rule=PathPrefix(`/v1/realtime`)
- traefik.http.routers.appwrite_realtime_wss.service=appwrite_realtime
- traefik.http.routers.appwrite_realtime_wss.tls=true
- traefik.http.routers.appwrite_realtime_wss.tls.certresolver=dns
networks:
- appwrite
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- redis
environment:
- _APP_ENV
- _APP_OPTIONS_ABUSE
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_USAGE_STATS
appwrite-worker-usage:
entrypoint: worker-usage
container_name: appwrite-worker-usage
@ -479,7 +530,7 @@ services:
container_name: appwrite-adminer
restart: always
ports:
- 9505:8080
- 9506:8080
networks:
- appwrite

View file

@ -1,4 +1,4 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1108px" height="839px" viewBox="-0.5 -0.5 1108 839" content="&lt;mxfile host=&quot;f62fd6c3-483a-4a80-b2dc-315a36eb41a5&quot; modified=&quot;2021-01-16T10:33:38.685Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 11_0_0) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.52.1 Chrome/83.0.4103.122 Electron/9.3.5 Safari/537.36&quot; etag=&quot;OZW_7vXQqOQ9aLjWoWdU&quot; version=&quot;13.10.0&quot; type=&quot;embed&quot;&gt;&lt;diagram id=&quot;WOshqXSVd2VkRfcggtcB&quot; name=&quot;Page-1&quot;&gt;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&lt;/diagram&gt;&lt;/mxfile&gt;">
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1108px" height="839px" viewBox="-0.5 -0.5 1108 839" content="&lt;mxfile host=&quot;612c770d-d23c-4897-ac0d-72478d1c5aef&quot; modified=&quot;2021-02-22T07:17:54.650Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.53.2 Chrome/87.0.4280.141 Electron/11.2.1 Safari/537.36&quot; etag=&quot;sAOb0L0gpVpMeEycjwCe&quot; version=&quot;14.2.4&quot; type=&quot;embed&quot;&gt;&lt;diagram id=&quot;WOshqXSVd2VkRfcggtcB&quot; name=&quot;Page-1&quot;&gt;7V1bc5s4FP41mem+7AgEAh7dJL3MJLOdOm23jzLIMVtiebDcxP31CzbYIMk2SWRJ2Elm2iAExud85/bpwgW8fHj6mOPZ5JYmJLtwQfJ0Aa8uXNcBvlf8V7Ys1y2BG64b7vM0qTptG4bpH1JfWbUu0oTMWx0ZpRlLZ+3GmE6nJGatNpzn9LHdbUyz9qfO8D0RGoYxzsTWH2nCJlWrC8D2xCeS3k8Yf2aE41/3OV1Mqw+c0ilZn3nA9X2qrvMJTuhjowleX8DLnFK2/uvh6ZJkpVxrka2v+7Dj7OaZczJlXS4I6+dgy/p7k6QQQ3VIczah93SKs+tt6/vVVyPlHUBxtO1zQ+msaHSKxv8IY8tKp3jBaNE0YQ9ZdZY8pezf8vK//eroZ+PM1VN159XBsjoYp1l2STOar54SFjIOE1i0z1lOf5HGGRd6nl883HtRFpV45nSRx9W3Dau28ls3OlXS+kjoA2H5suiQkwyz9HcbHLjC2P2m3+bSLzQtPtYFlT14qJJ0ZQ1OANq3YDi/J6y6aqux4o/GY2ybVnrcodPq4X7jbFE97g8yEvTc1uLjJGVkOMMruTwWFt3W2G4p79LLmE5Zox2sfor2DI9IVoAmIXl9emUfe/T1m+SMPO3VzlMbzpWQ/erwsWW/67ZJw3Q9sFudLUXsk7p77pYET8+SoGBJH7IFYyQ/C2va2IUJc/L6Yk6FfPNl46Ly8Gfz3Pay1dGxzdCvcqWVFazbIuVBrrMafcGC0n+GZ2E9HjBoPagv1nMsKwissoJAsILBNMlpcf4cLMFHBi0hPHdLiKyyhEiwhCHJCyjNz8ISAtecJURAEL0J0+gE2hqgLdD6pkAbiQX1YDZ7zAtEqkTt8VKPTYmkBWduj3AmSZYdV3kh21l0sC/BSlXR4yTJGMj8twMCGO01h4N6BJExPZ5d8XpEPUaBKTU6qDdllCI9OiPsEFemRwDQ9eDDRo+cMl5moMjTZY8iC3FDcTLCGZ7Gysk8FIdkNBaFm2ASjmNb6Ai9uadYAF/S6ZxmliZQDicsiHQKy2jJ6jR8ztYDHfI6LZ+zdUHdvM7YL39lpnTllb9do8cm4re8jKO8blhdOshzvGx0mJXjDfPGnblhi4gjQWDIjcwe6g8AB6L1E7x0bCOK3lCmEmXoNFDmq0XZxpG+wUwNzMKTgJmnHGbOG8xUwkx56WwEZr7imOkAI1TWycKszqD7DjPl3swoD3h6MDvOHD/dMEPKvZlRmvL0YOaeBsyUezOzLOozYMYTdiAmcjbUR74T7OWaDsOlnh9vgNcGgVGN6Oe1EcCRE8g06V4FaEW+vsbwjQ00OaA3XOFx6hTlY0NmymHlkV2cZvP1enhXtAy+fBYQYwXl7nGTjbVS7k696KghsC+LUXHZsPwXvPtKknT+l0rJlaOmoV8SIWKaEsYkNjZQhAJ5daNJEeJcGxMejtfIeOyuNCLoMEEj5KPOXsyBMi9mLhNwjBIa7UwA7I0fuzIB50AmIGgsdEcQIUm255Mw6R6PpJo0mAk44lqVSxxPyJl5Lx8a9V6eoIQrzPAIz1d6uMV5iq/eq9bExgft8lpWaAK5WjUhTvn4Nlc8zbiS1S6SgZP6+keNbB3ACXez8lGPcJEg3EEcF4JkJypeCLWKV5wtc0fww6liF+rFbrjHRZ+kfD294JWs8GA0LzdbOFHxaoVvfePWVMYYZ+mfIp+k0xOVsa8Vwq5Ygw5+Fz7iZLMHXy+EXUG8nwjOig8+TekiveAV68Dh8KZo+IgZecTL0xCyz5FUWmczO24P13dcvGrgVBX/5VaVw5H5rxex+D436TsM9rP4EL2uP/IUs/6ubw8s95N54vISEoIwFIE3AgSS18LLjllGPLxQtB8uAR9GItVwEav4K5IRRlSvxT6oXDMLYjhzDCRhOjxaBOnNCPzuEfNdY+wdTVU6nmts6a3j9majCL0aUe48u6++7I1GdM9See7qS7lqEdRmW7JdP+JFnrLy827wUvkazIODfGaGRLgUwJPMcYCyqsVVEXPq+Ga/PXFKI06hNqldRCiAGO2xwEOjw93MB8rGev3j7Hf43LTSc7g05kBaWa/wemn/ms3Y1Z9/Hq7/69NWaHSpj4qwcAiUz4f/a2BsbvIJdO3RpJl6FWraM5WvJf2ICyg79kx9rTMyXuNCkQ39Nlc8FmVthQsDgxUufONIX8iRQo0cqWCRLk9S8qmnIk+x8UB9JVvh2ZOtcpxaQrZGtgUiyZSpRZKy8+Bafb7o0BqJxOlUJky3o1HJmBpPuVF1F55FJJymCeKq3KMeOvVF7hGFtrlHkSG8xWl2Ht4ReQa9Y32TJjd7e/dFpeCPKKlIp6R6SDzZUdF4nswV2jHrIwh6Xoh4vWHRlEXoZy4dlsPPjkjMw894JPYs2oPaTF37BpfnwEVc8XaH57/OI3Hjpas3cbOIgOpZOiJ5OZAt6Uj4yvTCfDpi0Z7p/SIM5LC0I+7wsDQfd0RS7wcZTSg9k9ATuiZDj7iG0oTRdzMr35GYle+qNqvuwovscZD9ittSTVoStyO/53G7fn4bYNmvuC2HpR1xm4el8bhdC6v5SuXFNC7XLp9H4I74+Zo6A3cd9ppLfmj8S+306yPKSifd70sW0jLM5sUf4N0dych9jseqt/LZzOjcNQfUzEQyzovo3VTJl1BM6QMZkjwtl6qBd5+n42zxpH5fJTuV4TpGlSHuq2Qib+gYmUNJZEbmUn/fIm6kZ6m/TJOWpP4O8Pqe+1u0GrVnub8Ul3bk/gIuzSf/Im90WcS980j8HQBMZv4WsU79MvHa+Vpp4s5+k9Vv4rW0mlVTPCHJIlO+xtdWM48Mmjk6tzlqm5fq7n4Nb0czd0Uzj47z6lBx2Rw3HXez3ajiRTdipnrgRQX83LjndXcD1c5FpK9uCJuTaZwvZ8fYe/YoYV8niYVEEmswZen3NF+sqJPLDD8MvivfkDqIRrINScZjgsxtSB0aJU6QRTVWz3czQbIJeus9RnSwOEjkI79eD27uPt9eXxz3BSsC1iWy2gl/xPmhY75gpTjMKWVNT198z8ktTUjZ438=&lt;/diagram&gt;&lt;/mxfile&gt;">
<defs/>
<g>
<path d="M 77 40 L 77 80 L 397 80 L 397 113.63" fill="none" stroke="#23445d" stroke-miterlimit="10" pointer-events="stroke"/>
@ -117,10 +117,12 @@
</g>
<path d="M 457 220 L 500.63 220" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 505.88 220 L 498.88 223.5 L 500.63 220 L 498.88 216.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 397 240 L 397 275 L 597 275 L 597 303.63" fill="none" stroke="#10739e" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 597 308.88 L 593.5 301.88 L 597 303.63 L 600.5 301.88 Z" fill="#10739e" stroke="#10739e" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 397 240 L 397 275 L 437 275 L 437 303.63" fill="none" stroke="#10739e" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 437 308.88 L 433.5 301.88 L 437 303.63 L 440.5 301.88 Z" fill="#10739e" stroke="#10739e" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 397 240 L 397 275 L 97 275 L 97 303.63" fill="none" stroke="#10739e" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 97 308.88 L 93.5 301.88 L 97 303.63 L 100.5 301.88 Z" fill="#10739e" stroke="#10739e" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 397 240 L 397 275 L 597 275 L 597 303.63" fill="none" stroke="#006eaf" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 597 308.88 L 593.5 301.88 L 597 303.63 L 600.5 301.88 Z" fill="#006eaf" stroke="#006eaf" stroke-miterlimit="10" pointer-events="all"/>
<rect x="337" y="200" width="120" height="40" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
@ -138,8 +140,6 @@
</text>
</switch>
</g>
<path d="M 97 350 L 97 380 L 597 380 L 597 356.37" fill="none" stroke="#6c8ebf" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 597 351.12 L 600.5 358.12 L 597 356.37 L 593.5 358.12 Z" fill="#6c8ebf" stroke="#6c8ebf" stroke-miterlimit="10" pointer-events="all"/>
<rect x="37" y="310" width="120" height="40" fill="#ffffff" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
@ -157,42 +157,40 @@
</text>
</switch>
</g>
<path d="M 657 330 L 897 330 L 897 250 L 960.63 250" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 250 L 960.63 250" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 250 L 958.88 253.5 L 960.63 250 L 958.88 246.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 300 L 960.63 300" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 300 L 960.63 300" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 300 L 958.88 303.5 L 960.63 300 L 958.88 296.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 400 L 960.63 400" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 400 L 960.63 400" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 400 L 958.88 403.5 L 960.63 400 L 958.88 396.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 450 L 960.63 450" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 450 L 960.63 450" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 450 L 958.88 453.5 L 960.63 450 L 958.88 446.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 500 L 960.63 500" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 500 L 960.63 500" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 500 L 958.88 503.5 L 960.63 500 L 958.88 496.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 550 L 960.63 550" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 550 L 960.63 550" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 550 L 958.88 553.5 L 960.63 550 L 958.88 546.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 600 L 960.63 600" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 497 330 L 897 330 L 897 600 L 960.63 600" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 600 L 958.88 603.5 L 960.63 600 L 958.88 596.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 537 330 L 347 330 L 347 493.63" fill="none" stroke="#56517e" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 347 498.88 L 343.5 491.88 L 347 493.63 L 350.5 491.88 Z" fill="#56517e" stroke="#56517e" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 537 330 L 207 330 L 207 493.63" fill="none" stroke="#56517e" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 377 330 L 207 330 L 207 493.63" fill="none" stroke="#56517e" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 207 498.88 L 203.5 491.88 L 207 493.63 L 210.5 491.88 Z" fill="#56517e" stroke="#56517e" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 597 350 L 597 403.63" fill="none" stroke="#82b366" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 597 408.88 L 593.5 401.88 L 597 403.63 L 600.5 401.88 Z" fill="#82b366" stroke="#82b366" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 657 330 L 897 330 L 897 350 L 960.63 350" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 437 350 L 437 380 L 597 380 L 597 403.63" fill="none" stroke="#2d7600" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 597 408.88 L 593.5 401.88 L 597 403.63 L 600.5 401.88 Z" fill="#2d7600" stroke="#2d7600" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 497 330 L 897 330 L 897 350 L 960.63 350" fill="none" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 965.88 350 L 958.88 353.5 L 960.63 350 L 958.88 346.5 Z" fill="#d4d4d4" stroke="#d4d4d4" stroke-miterlimit="10" pointer-events="all"/>
<rect x="537" y="310" width="120" height="40" fill="#ffffff" stroke="none" pointer-events="all"/>
<rect x="377" y="310" width="120" height="40" fill="#ffffff" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 330px; margin-left: 538px;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 330px; margin-left: 378px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
APIs
REST API
</div>
</div>
</div>
</foreignObject>
<text x="597" y="334" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
APIs
<text x="437" y="334" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
REST API
</text>
</switch>
</g>
@ -425,10 +423,12 @@
</text>
</switch>
</g>
<path d="M 597 430 L 597 465 L 527 465 L 527 493.63" fill="none" stroke="#82b366" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 527 498.88 L 523.5 491.88 L 527 493.63 L 530.5 491.88 Z" fill="#82b366" stroke="#82b366" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 597 430 L 597 465 L 667 465 L 667 493.63" fill="none" stroke="#82b366" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 667 498.88 L 663.5 491.88 L 667 493.63 L 670.5 491.88 Z" fill="#82b366" stroke="#82b366" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 597 430 L 597 465 L 527 465 L 527 493.63" fill="none" stroke="#2d7600" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 527 498.88 L 523.5 491.88 L 527 493.63 L 530.5 491.88 Z" fill="#2d7600" stroke="#2d7600" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 597 430 L 597 465 L 667 465 L 667 493.63" fill="none" stroke="#2d7600" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 667 498.88 L 663.5 491.88 L 667 493.63 L 670.5 491.88 Z" fill="#2d7600" stroke="#2d7600" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 597 430 L 597 465 L 807 465 L 807 493.63" fill="none" stroke="#2d7600" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 807 498.88 L 803.5 491.88 L 807 493.63 L 810.5 491.88 Z" fill="#2d7600" stroke="#2d7600" stroke-miterlimit="10" pointer-events="all"/>
<rect x="437" y="410" width="320" height="20" fill="#d5e8d4" stroke="#82b366" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
@ -724,10 +724,29 @@
</text>
</switch>
</g>
<path d="M 597 350 L 597 403.63" fill="none" stroke="#2d7600" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 597 408.88 L 593.5 401.88 L 597 403.63 L 600.5 401.88 Z" fill="#2d7600" stroke="#2d7600" stroke-miterlimit="10" pointer-events="all"/>
<rect x="537" y="310" width="120" height="40" fill="#ffffff" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 330px; margin-left: 538px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
REALTIME API
</div>
</div>
</div>
</foreignObject>
<text x="597" y="334" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
REALTIME API
</text>
</switch>
</g>
</g>
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
<a transform="translate(0,-5)" xlink:href="https://desk.draw.io/support/solutions/articles/16000042487" target="_blank">
<a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
Viewer does not support full SVG 1.1
</text>

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View file

@ -27,6 +27,7 @@ const configApp = {
'public/scripts/services/sdk.js',
'public/scripts/services/search.js',
'public/scripts/services/timezone.js',
'public/scripts/services/realtime.js',
'public/scripts/routes.js',
'public/scripts/filters.js',
@ -41,6 +42,7 @@ const configApp = {
'public/scripts/views/forms/clone.js',
'public/scripts/views/forms/add.js',
'public/scripts/views/forms/chart.js',
'public/scripts/views/forms/chart-bar.js',
'public/scripts/views/forms/code.js',
'public/scripts/views/forms/color.js',
'public/scripts/views/forms/copy.js',

View file

@ -20,6 +20,7 @@
<directory>./tests/e2e/General</directory>
<directory>./tests/e2e/Scopes</directory>
<directory>./tests/e2e/Services/Account</directory>
<directory>./tests/e2e/Services/Realtime</directory>
<directory>./tests/e2e/Services/Avatars</directory>
<directory>./tests/e2e/Services/Database</directory>
<directory>./tests/e2e/Services/Health</directory>

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,13 @@ function rejected(value){try{step(generator["throw"](value));}catch(e){reject(e)
function step(result){result.done?resolve(result.value):adopt(result.value).then(fulfilled,rejected);}
step((generator=generator.apply(thisArg,_arguments||[])).next());});}
class AppwriteException extends Error{constructor(message,code=0,response=''){super(message);this.name='AppwriteException';this.message=message;this.code=code;this.response=response;}}
class Appwrite{constructor(){this.config={endpoint:'https://appwrite.io/v1',project:'',key:'',jwt:'',locale:'',mode:'',};this.headers={'x-sdk-version':'appwrite:web:2.1.0','X-Appwrite-Response-Format':'0.9.0',};this.account={get:()=>__awaiter(this,void 0,void 0,function*(){let path='/account';let payload={};const uri=new URL(this.config.endpoint+path);return yield this.call('get',uri,{'content-type':'application/json',},payload);}),create:(email,password,name)=>__awaiter(this,void 0,void 0,function*(){if(typeof email==='undefined'){throw new AppwriteException('Missing required parameter: "email"');}
class Appwrite{constructor(){this.config={endpoint:'https://appwrite.io/v1',endpointRealtime:'',project:'',key:'',jwt:'',locale:'',mode:'',};this.headers={'x-sdk-version':'appwrite:web:2.1.0','X-Appwrite-Response-Format':'0.9.0',};this.realtime={socket:undefined,timeout:undefined,channels:{},lastMessage:undefined,createSocket:()=>{var _a,_b;const channels=new URLSearchParams();channels.set('project',this.config.project);for(const property in this.realtime.channels){channels.append('channels[]',property);}
if(((_a=this.realtime.socket)===null||_a===void 0?void 0:_a.readyState)===WebSocket.OPEN){this.realtime.socket.close();}
this.realtime.socket=new WebSocket(this.config.endpointRealtime+'/realtime?'+channels.toString());(_b=this.realtime.socket)===null||_b===void 0?void 0:_b.addEventListener('message',this.realtime.authenticate);for(const channel in this.realtime.channels){this.realtime.channels[channel].forEach(callback=>{var _a;(_a=this.realtime.socket)===null||_a===void 0?void 0:_a.addEventListener('message',callback);});}
this.realtime.socket.addEventListener('close',event=>{var _a,_b,_c;if(((_b=(_a=this.realtime)===null||_a===void 0?void 0:_a.lastMessage)===null||_b===void 0?void 0:_b.type)==='error'&&((_c=this.realtime)===null||_c===void 0?void 0:_c.lastMessage.data).code===1008){return;}
console.error('Realtime got disconnected. Reconnect will be attempted in 1 second.',event.reason);setTimeout(()=>{this.realtime.createSocket();},1000);});},authenticate:(event)=>{var _a,_b;const message=JSON.parse(event.data);if(message.type==='connected'){const cookie=JSON.parse((_a=window.localStorage.getItem('cookieFallback'))!==null&&_a!==void 0?_a:"{}");const session=cookie===null||cookie===void 0?void 0:cookie[`a_session_${this.config.project}`];const data=message.data;if(session&&!data.user){(_b=this.realtime.socket)===null||_b===void 0?void 0:_b.send(JSON.stringify({type:"authentication",data:{session}}));}}},onMessage:(channel,callback)=>(event)=>{try{const message=JSON.parse(event.data);this.realtime.lastMessage=message;if(message.type==='event'){let data=message.data;if(data.channels&&data.channels.includes(channel)){callback(data);}}
else if(message.type==='error'){throw message.data;}}
catch(e){console.error(e);}}};this.account={get:()=>__awaiter(this,void 0,void 0,function*(){let path='/account';let payload={};const uri=new URL(this.config.endpoint+path);return yield this.call('get',uri,{'content-type':'application/json',},payload);}),create:(email,password,name)=>__awaiter(this,void 0,void 0,function*(){if(typeof email==='undefined'){throw new AppwriteException('Missing required parameter: "email"');}
if(typeof password==='undefined'){throw new AppwriteException('Missing required parameter: "password"');}
let path='/account';let payload={};if(typeof email!=='undefined'){payload['email']=email;}
if(typeof password!=='undefined'){payload['password']=password;}
@ -449,12 +455,15 @@ const uri=new URL(this.config.endpoint+path);return yield this.call('patch',uri,
if(typeof emailVerification==='undefined'){throw new AppwriteException('Missing required parameter: "emailVerification"');}
let path='/users/{userId}/verification'.replace('{userId}',userId);let payload={};if(typeof emailVerification!=='undefined'){payload['emailVerification']=emailVerification;}
const uri=new URL(this.config.endpoint+path);return yield this.call('patch',uri,{'content-type':'application/json',},payload);})};}
setEndpoint(endpoint){this.config.endpoint=endpoint;return this;}
setEndpoint(endpoint){this.config.endpoint=endpoint;this.config.endpointRealtime=this.config.endpointRealtime||this.config.endpoint.replace("https://","wss://").replace("http://","ws://");return this;}
setEndpointRealtime(endpointRealtime){this.config.endpointRealtime=endpointRealtime;return this;}
setProject(value){this.headers['X-Appwrite-Project']=value;this.config.project=value;return this;}
setKey(value){this.headers['X-Appwrite-Key']=value;this.config.key=value;return this;}
setJWT(value){this.headers['X-Appwrite-JWT']=value;this.config.jwt=value;return this;}
setLocale(value){this.headers['X-Appwrite-Locale']=value;this.config.locale=value;return this;}
setMode(value){this.headers['X-Appwrite-Mode']=value;this.config.mode=value;return this;}
subscribe(channels,callback){let channelArray=typeof channels==='string'?[channels]:channels;let savedChannels=[];channelArray.forEach((channel,index)=>{if(!(channel in this.realtime.channels)){this.realtime.channels[channel]=[];}
savedChannels[index]={name:channel,index:(this.realtime.channels[channel].push(this.realtime.onMessage(channel,callback))-1)};clearTimeout(this.realtime.timeout);this.realtime.timeout=window===null||window===void 0?void 0:window.setTimeout(()=>{this.realtime.createSocket();},1);});return()=>{savedChannels.forEach(channel=>{var _a;(_a=this.realtime.socket)===null||_a===void 0?void 0:_a.removeEventListener('message',this.realtime.channels[channel.name][channel.index]);this.realtime.channels[channel.name].splice(channel.index,1);});};}
call(method,url,headers={},params={}){var _a,_b;return __awaiter(this,void 0,void 0,function*(){method=method.toUpperCase();headers=Object.assign(Object.assign({},headers),this.headers);let options={method,headers,credentials:'include'};if(typeof window!=='undefined'&&window.localStorage){headers['X-Fallback-Cookies']=(_a=window.localStorage.getItem('cookieFallback'))!==null&&_a!==void 0?_a:"";}
if(method==='GET'){for(const[key,value]of Object.entries(this.flatten(params))){url.searchParams.append(key,value);}}
else{switch(headers['content-type']){case'application/json':options.body=JSON.stringify(params);break;case'multipart/form-data':let formData=new FormData();for(const key in params){if(Array.isArray(params[key])){formData.append(key+'[]',params[key].join(','));}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -39,6 +39,7 @@
constructor() {
this.config = {
endpoint: 'https://appwrite.io/v1',
endpointRealtime: '',
project: '',
key: '',
jwt: '',
@ -49,6 +50,76 @@
'x-sdk-version': 'appwrite:web:2.1.0',
'X-Appwrite-Response-Format': '0.9.0',
};
this.realtime = {
socket: undefined,
timeout: undefined,
channels: {},
lastMessage: undefined,
createSocket: () => {
var _a, _b;
const channels = new URLSearchParams();
channels.set('project', this.config.project);
for (const property in this.realtime.channels) {
channels.append('channels[]', property);
}
if (((_a = this.realtime.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
this.realtime.socket.close();
}
this.realtime.socket = new WebSocket(this.config.endpointRealtime + '/realtime?' + channels.toString());
(_b = this.realtime.socket) === null || _b === void 0 ? void 0 : _b.addEventListener('message', this.realtime.authenticate);
for (const channel in this.realtime.channels) {
this.realtime.channels[channel].forEach(callback => {
var _a;
(_a = this.realtime.socket) === null || _a === void 0 ? void 0 : _a.addEventListener('message', callback);
});
}
this.realtime.socket.addEventListener('close', event => {
var _a, _b, _c;
if (((_b = (_a = this.realtime) === null || _a === void 0 ? void 0 : _a.lastMessage) === null || _b === void 0 ? void 0 : _b.type) === 'error' && ((_c = this.realtime) === null || _c === void 0 ? void 0 : _c.lastMessage.data).code === 1008) {
return;
}
console.error('Realtime got disconnected. Reconnect will be attempted in 1 second.', event.reason);
setTimeout(() => {
this.realtime.createSocket();
}, 1000);
});
},
authenticate: (event) => {
var _a, _b;
const message = JSON.parse(event.data);
if (message.type === 'connected') {
const cookie = JSON.parse((_a = window.localStorage.getItem('cookieFallback')) !== null && _a !== void 0 ? _a : "{}");
const session = cookie === null || cookie === void 0 ? void 0 : cookie[`a_session_${this.config.project}`];
const data = message.data;
if (session && !data.user) {
(_b = this.realtime.socket) === null || _b === void 0 ? void 0 : _b.send(JSON.stringify({
type: "authentication",
data: {
session
}
}));
}
}
},
onMessage: (channel, callback) => (event) => {
try {
const message = JSON.parse(event.data);
this.realtime.lastMessage = message;
if (message.type === 'event') {
let data = message.data;
if (data.channels && data.channels.includes(channel)) {
callback(data);
}
}
else if (message.type === 'error') {
throw message.data;
}
}
catch (e) {
console.error(e);
}
}
};
this.account = {
/**
* Get Account
@ -4117,6 +4188,18 @@
*/
setEndpoint(endpoint) {
this.config.endpoint = endpoint;
this.config.endpointRealtime = this.config.endpointRealtime || this.config.endpoint.replace("https://", "wss://").replace("http://", "ws://");
return this;
}
/**
* Set Realtime Endpoint
*
* @param {string} endpointRealtime
*
* @returns {this}
*/
setEndpointRealtime(endpointRealtime) {
this.config.endpointRealtime = endpointRealtime;
return this;
}
/**
@ -4185,6 +4268,55 @@
this.config.mode = value;
return this;
}
/**
* Subscribes to Appwrite events and passes you the payload in realtime.
*
* @param {string|string[]} channels
* Channel to subscribe - pass a single channel as a string or multiple with an array of strings.
*
* Possible channels are:
* - account
* - collections
* - collections.[ID]
* - collections.[ID].documents
* - documents
* - documents.[ID]
* - files
* - files.[ID]
* - executions
* - executions.[ID]
* - functions.[ID]
* - teams
* - teams.[ID]
* - memberships
* - memberships.[ID]
* @param {(payload: RealtimeMessage) => void} callback Is called on every realtime update.
* @returns {() => void} Unsubscribes from events.
*/
subscribe(channels, callback) {
let channelArray = typeof channels === 'string' ? [channels] : channels;
let savedChannels = [];
channelArray.forEach((channel, index) => {
if (!(channel in this.realtime.channels)) {
this.realtime.channels[channel] = [];
}
savedChannels[index] = {
name: channel,
index: (this.realtime.channels[channel].push(this.realtime.onMessage(channel, callback)) - 1)
};
clearTimeout(this.realtime.timeout);
this.realtime.timeout = window === null || window === void 0 ? void 0 : window.setTimeout(() => {
this.realtime.createSocket();
}, 1);
});
return () => {
savedChannels.forEach(channel => {
var _a;
(_a = this.realtime.socket) === null || _a === void 0 ? void 0 : _a.removeEventListener('message', this.realtime.channels[channel.name][channel.index]);
this.realtime.channels[channel.name].splice(channel.index, 1);
});
};
}
call(method, url, headers = {}, params = {}) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {

View file

@ -255,6 +255,9 @@ window.ls.filter
return '';
})
.add("accessProject", function($value, router) {
return ($value && $value.hasOwnProperty(router.params.project)) ? $value[router.params.project] : 0;
})
;
function abbreviate(number, maxPlaces, forcePlaces, forceLetter) {

View file

@ -26,7 +26,7 @@ document.addEventListener("account.create", function () {
let promise = sdk.account.createSession(form.email, form.password);
container.set("serviceForm", {}, true, true); // Remove sensetive data when not needed
promise.then(function () {
var subscribe = document.getElementById('newsletter').checked;
if (subscribe) {
@ -51,4 +51,75 @@ document.addEventListener("account.create", function () {
}, function (error) {
window.location = '/auth/signup?failure=1';
});
});
});
window.addEventListener("load", async () => {
const bars = 12;
const realtime = window.ls.container.get('realtime');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let current = {};
window.ls.container.get('console').subscribe('project', event => {
for (let project in event.payload) {
current[project] = event.payload[project] ?? 0;
}
});
while (true) {
let newHistory = {};
let createdHistory = false;
for (const project in current) {
let history = realtime?.history ?? {};
if (!(project in history)) {
history[project] = new Array(bars).fill({
percentage: 0,
value: 0
});
}
history = history[project];
history.push({
percentage: 0,
value: current[project]
});
if (history.length >= bars) {
history.shift();
}
const highest = history.reduce((prev, curr) => {
return (curr.value > prev) ? curr.value : prev;
}, 0);
history = history.map(({ percentage, value }) => {
createdHistory = true;
percentage = value === 0 ? 0 : ((Math.round((value / highest) * 10) / 10) * 100);
if (percentage > 100) percentage = 100;
else if (percentage == 0 && value != 0) percentage = 5;
return {
percentage: percentage,
value: value
};
})
newHistory[project] = history;
}
let currentSnapshot = { ...current };
for (let index = .1; index <= 1; index += .05) {
let currentTransition = { ...currentSnapshot };
for (const project in current) {
if (project in newHistory) {
let base = newHistory[project][bars - 2].value;
let cur = currentSnapshot[project];
let offset = (cur - base) * index;
currentTransition[project] = base + Math.floor(offset);
}
}
realtime.setCurrent(currentTransition);
await sleep(250);
}
realtime.setHistory(newHistory);
}
});

View file

@ -0,0 +1,20 @@
(function (window) {
"use strict";
window.ls.container.set('realtime', () => {
return {
current: null,
history: null,
setCurrent: function(currentConnections) {
var scope = this;
scope.current = currentConnections;
return scope.current;
},
setHistory: function(history) {
var scope = this;
scope.history = history;
return scope.history;
}
};
}, true, true);
})(window);

View file

@ -0,0 +1,42 @@
(function (window) {
"use strict";
window.ls.container.get("view").add({
selector: "data-forms-chart-bars",
controller: (element) => {
let observer = null;
let populateChart = () => {
let history = element.dataset?.history;
if (history == 0) {
history = new Array(12).fill({
percentage: 0,
value: 0
});
} else {
history = JSON.parse(history);
}
element.innerHTML = '';
history.forEach(({ percentage, value }, index) => {
const seconds = 60 - (index * 5);
const bar = document.createElement('span');
bar.classList.add('bar');
bar.classList.add(`bar-${percentage}`);
bar.classList.add('tooltip');
bar.classList.add('down');
bar.setAttribute('data-tooltip', `${value} (${seconds} seconds ago)`);
element.appendChild(bar);
})
}
if (observer) {
observer.disconnect();
} else {
observer = new MutationObserver(populateChart);
observer.observe(element, {
attributes: true,
attributeFilter: ['data-history']
});
}
populateChart();
}
});
})(window);

View file

@ -2,10 +2,10 @@
position: relative;
background: var(--config-color-background-fade);
border-radius: 10px;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.05);
padding: 30px;
display: block;
border-bottom: none;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.05);
display: block;
padding: 30px;
&.padding-tiny {
padding: 5px;

View file

@ -466,7 +466,7 @@ fieldset {
white-space: nowrap;
background: var(--config-color-tooltip-background);
border-radius: 5px;
bottom: 26px;
bottom: calc(100% + 6px);
color: var(--config-color-tooltip-text);
content: attr(data-tooltip);
padding: 5px 15px;
@ -483,22 +483,23 @@ fieldset {
border: solid;
border-color: var(--config-color-tooltip-background) transparent;
border-width: 6px 6px 0 6px;
bottom: 20px;
bottom: 100%;
content: "";
position: absolute;
z-index: 99;
.func-start(5px);
.func-start(3px);
}
&.down:hover:after {
top: 26px;
bottom: inherit;
}
&.down:hover:before {
top: 20px;
border-width: 0 6px 6px 6px;
bottom: inherit;
&.down:hover {
&:after {
top: calc(100% + 6px);
bottom: inherit;
}
&:before {
top: 100%;
border-width: 0 6px 6px 6px;
bottom: inherit;
}
}
}

View file

@ -408,7 +408,7 @@
.dashboard {
padding: 20px;
overflow: hidden;
overflow: visible;
position: relative;
z-index: 1;
margin-bottom: 2px;
@ -522,6 +522,44 @@
padding: 0;
border: none;
}
.chart-bar {
height: 4rem;
width: auto;
display: flex;
align-items: flex-end;
@media @desktops {
padding-right: 15px;
}
.bar {
width: 12.5%;
background-color: var(--config-color-chart-fade);
margin: 0 2px;
border-top: 2px solid var(--config-color-chart);
&:hover {
background-color: var(--config-color-chart);
}
.bar-loop (@i) when (@i > -1) {
&.bar-@{i} {
height: ~"@{i}%";
}
&.bar-@{i} when(@i = 0) {
border-top: 1px solid var(--config-color-chart);
}
.bar-loop(@i - 10);
}
.bar-loop (100);
&.bar-5 {
height: 5%;
}
}
}
}
.chart-metric {
@ -587,10 +625,10 @@
}
&:nth-child(1), &.blue {
color: #29b5d9;
color: var(--config-color-chart);
&::before {
background: #29b5d9;
background: var(--config-color-chart);
}
}

View file

@ -23,12 +23,15 @@
--config-color-normal: #40404c;
--config-color-dark: #313131;
--config-color-fade: #8f8f8f;
--config-color-fade-dark: #6e6e6e;
--config-color-fade-light: #e2e2e2;
--config-color-fade-super: #f1f3f5;
--config-color-danger: #f53d3d;
--config-color-success: #1bbf61;
--config-color-warning: #fffbdd;
--config-color-info: #386fd2;
--config-color-chart: #29b5d9;
--config-color-chart-fade: #d4f0f7;
--config-border-color: #f3f3f3;
--config-border-fade: #e0e3e4;
--config-border-radius: 10px;
@ -105,12 +108,15 @@
--config-color-normal: #c7d8eb;
--config-color-dark: #c7d8eb;
--config-color-fade: #bec3e0;
--config-color-fade-dark: #81859b;
--config-color-fade-light: #181818;
--config-color-fade-super: #262D50;
--config-color-danger: #d84a4a;
--config-color-success: #34b86d;
--config-color-warning: #e0d56d;
--config-color-info: #386fd2;
--config-color-chart: #29b5d9;
--config-color-chart-fade: #c7d8eb;
--config-border-color: #262D50;
--config-border-fade: #19203a;
--config-prism-background: #1F253F;

View file

@ -95,6 +95,10 @@ small {
font-size: 13px;
}
.text-size-smaller {
font-size: 11.5px;
}
.text-size-xs {
font-size: 10px;
}
@ -154,6 +158,10 @@ small {
color: var(--config-color-fade);
}
.text-fade-dark {
color: var(--config-color-fade-dark);
}
&.text-green {
color: var(--config-color-success);
}

View file

@ -273,4 +273,32 @@ class Auth
return false;
}
/**
* Returns all roles for a user.
*
* @param Document $user
* @return array
*/
public static function getRoles(Document $user): array
{
if ($user->getId()) {
$roles[] = 'user:'.$user->getId();
$roles[] = 'role:'.Auth::USER_ROLE_MEMBER;
} else {
return ['role:'.Auth::USER_ROLE_GUEST];
}
foreach ($user->getAttribute('memberships', []) as $node) {
if (isset($node['teamId']) && isset($node['roles'])) {
$roles[] = 'team:' . $node['teamId'];
foreach ($node['roles'] as $nodeRole) { // Set all team roles
$roles[] = 'team:' . $node['teamId'] . '/' . $nodeRole;
}
}
}
return $roles;
}
}

View file

@ -41,6 +41,9 @@ class Database
const SYSTEM_COLLECTION_FUNCTIONS = 'functions';
const SYSTEM_COLLECTION_TAGS = 'tags';
const SYSTEM_COLLECTION_EXECUTIONS = 'executions';
// Realtime
const SYSTEM_COLLECTION_CONNECTIONS = 'connections';
// Var Types
const SYSTEM_VAR_TYPE_TEXT = 'text';

View file

@ -0,0 +1,10 @@
<?php
namespace Appwrite\Messaging;
abstract class Adapter
{
public abstract function subscribe(string $projectId, mixed $identifier, array $roles, array $channels): void;
public abstract function unsubscribe(mixed $identifier): void;
public static abstract function send(string $projectId, array $payload, string $event, array $channels, array $roles, array $options): void;
}

View file

@ -0,0 +1,313 @@
<?php
namespace Appwrite\Messaging\Adapter;
use Appwrite\Database\Document;
use Appwrite\Messaging\Adapter;
use Redis;
use Utopia\App;
class Realtime extends Adapter
{
/**
* Connection Tree
*
* [CONNECTION_ID] ->
* 'projectId' -> [PROJECT_ID]
* 'roles' -> [ROLE_x, ROLE_Y]
* 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z]
*/
public array $connections = [];
/**
* Subscription Tree
*
* [PROJECT_ID] ->
* [ROLE_X] ->
* [CHANNEL_NAME_X] -> [CONNECTION_ID]
* [CHANNEL_NAME_Y] -> [CONNECTION_ID]
* [CHANNEL_NAME_Z] -> [CONNECTION_ID]
* [ROLE_Y] ->
* [CHANNEL_NAME_X] -> [CONNECTION_ID]
* [CHANNEL_NAME_Y] -> [CONNECTION_ID]
* [CHANNEL_NAME_Z] -> [CONNECTION_ID]
*/
public array $subscriptions = [];
/**
* Adds a subscription.
*
* @param string $projectId
* @param mixed $identifier
* @param array $roles
* @param array $channels
* @return void
*/
public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels): void
{
if (!isset($this->subscriptions[$projectId])) { // Init Project
$this->subscriptions[$projectId] = [];
}
foreach ($roles as $role) {
if (!isset($this->subscriptions[$projectId][$role])) { // Add user first connection
$this->subscriptions[$projectId][$role] = [];
}
foreach ($channels as $channel => $list) {
$this->subscriptions[$projectId][$role][$channel][$identifier] = true;
}
}
$this->connections[$identifier] = [
'projectId' => $projectId,
'roles' => $roles,
'channels' => $channels
];
}
/**
* Removes Subscription.
*
* @param mixed $connection
* @return void
*/
public function unsubscribe(mixed $connection): void
{
$projectId = $this->connections[$connection]['projectId'] ?? '';
$roles = $this->connections[$connection]['roles'] ?? [];
foreach ($roles as $role) {
foreach ($this->subscriptions[$projectId][$role] as $channel => $list) {
unset($this->subscriptions[$projectId][$role][$channel][$connection]); // Remove connection
if (empty($this->subscriptions[$projectId][$role][$channel])) {
unset($this->subscriptions[$projectId][$role][$channel]); // Remove channel when no connections
}
}
if (empty($this->subscriptions[$projectId][$role])) {
unset($this->subscriptions[$projectId][$role]); // Remove role when no channels
}
}
if (empty($this->subscriptions[$projectId])) { // Remove project when no roles
unset($this->subscriptions[$projectId]);
}
unset($this->connections[$connection]);
}
/**
* Checks if Channel has a subscriber.
* @param string $projectId
* @param string $role
* @param string $channel
* @return bool
*/
public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool
{
//TODO: look into moving it to an abstract class in the parent class
if (empty($channel)) {
return array_key_exists($projectId, $this->subscriptions)
&& array_key_exists($role, $this->subscriptions[$projectId]);
}
return array_key_exists($projectId, $this->subscriptions)
&& array_key_exists($role, $this->subscriptions[$projectId])
&& array_key_exists($channel, $this->subscriptions[$projectId][$role]);
}
/**
* Sends an event to the Realtime Server.
* @param string $projectId
* @param array $payload
* @param string $event
* @param array $channels
* @param array $roles
* @param array $options
* @return void
*/
public static function send(string $projectId, array $payload, string $event, array $channels, array $roles, array $options = []): void
{
if (empty($channels) || empty($roles) || empty($projectId)) return;
$permissionsChanged = array_key_exists('permissionsChanged', $options) && $options['permissionsChanged'];
$userId = array_key_exists('userId', $options) ? $options['userId'] : null;
$redis = new \Redis(); //TODO: make this part of the constructor
$redis->connect(App::getEnv('_APP_REDIS_HOST', ''), App::getEnv('_APP_REDIS_PORT', ''));
$redis->publish('realtime', json_encode([
'project' => $projectId,
'roles' => $roles,
'permissionsChanged' => $permissionsChanged,
'userId' => $userId,
'data' => [
'event' => $event,
'channels' => $channels,
'timestamp' => time(),
'payload' => $payload
]
]));
}
/**
* Identifies the receivers of all subscriptions, based on the permissions and event.
*
* Example of performance with an event with user:XXX permissions and with X users spread across 10 different channels:
* - 0.014 ms (±6.88%) | 10 Connections / 100 Subscriptions
* - 0.070 ms (±3.71%) | 100 Connections / 1,000 Subscriptions
* - 0.846 ms (±2.74%) | 1,000 Connections / 10,000 Subscriptions
* - 10.866 ms (±1.01%) | 10,000 Connections / 100,000 Subscriptions
* - 110.201 ms (±2.32%) | 100,000 Connections / 1,000,000 Subscriptions
* - 1,121.328 ms (±0.84%) | 1,000,000 Connections / 10,000,000 Subscriptions
*
* @param array $event
*/
public function getSubscribers(array $event)
{
$receivers = [];
/**
* Check if project has subscriber.
*/
if (isset($this->subscriptions[$event['project']])) {
/**
* Iterate through each role.
*/
foreach ($this->subscriptions[$event['project']] as $role => $subscription) {
/**
* Iterate through each channel.
*/
foreach ($event['data']['channels'] as $channel) {
/**
* Check if channel has subscriber. Also taking care of the role in the event and the wildcard role.
*/
if (
\array_key_exists($channel, $this->subscriptions[$event['project']][$role])
&& (\in_array($role, $event['roles']) || \in_array('*', $event['roles']))
) {
/**
* Saving all connections that are allowed to receive this event.
*/
foreach (array_keys($this->subscriptions[$event['project']][$role][$channel]) as $id) {
/**
* To prevent duplicates, we save the connections as array keys.
*/
$receivers[$id] = 0;
}
break;
}
}
}
}
return array_keys($receivers);
}
/**
* Converts the channels from the Query Params into an array.
* Also renames the account channel to account.USER_ID and removes all illegal account channel variations.
* @param array $channels
* @param string $userId
* @return array
*/
public static function convertChannels(array $channels, string $userId): array
{
$channels = array_flip($channels);
foreach ($channels as $key => $value) {
switch (true) {
case strpos($key, 'account.') === 0:
unset($channels[$key]);
break;
case $key === 'account':
if (!empty($userId)) {
$channels['account.' . $userId] = $value;
}
break;
}
}
return $channels;
}
/**
* Create channels array based on the event name and payload.
*
* @param string $event
* @param Document $payload
* @return array
*/
public static function fromPayload(string $event, Document $payload): array
{
$channels = [];
$roles = [];
$permissionsChanged = false;
switch (true) {
case strpos($event, 'account.recovery.') === 0:
case strpos($event, 'account.sessions.') === 0:
case strpos($event, 'account.verification.') === 0:
$channels[] = 'account';
$channels[] = 'account.' . $payload->getAttribute('userId');
$roles = ['user:' . $payload->getAttribute('userId')];
break;
case strpos($event, 'account.') === 0:
$channels[] = 'account';
$channels[] = 'account.' . $payload->getId();
$roles = ['user:' . $payload->getId()];
break;
case strpos($event, 'teams.memberships') === 0:
$permissionsChanged = in_array($event, ['teams.memberships.update', 'teams.memberships.delete', 'teams.memberships.update.status']);
$channels[] = 'memberships';
$channels[] = 'memberships.' . $payload->getId();
$roles = ['team:' . $payload->getAttribute('teamId')];
break;
case strpos($event, 'teams.') === 0:
$permissionsChanged = $event === 'teams.create';
$channels[] = 'teams';
$channels[] = 'teams.' . $payload->getId();
$roles = ['team:' . $payload->getId()];
break;
case strpos($event, 'database.collections.') === 0:
$channels[] = 'collections';
$channels[] = 'collections.' . $payload->getId();
$roles = $payload->getAttribute('$permissions.read');
break;
case strpos($event, 'database.documents.') === 0:
$channels[] = 'documents';
$channels[] = 'collections.' . $payload->getAttribute('$collection') . '.documents';
$channels[] = 'documents.' . $payload->getId();
$roles = $payload->getAttribute('$permissions.read');
break;
case strpos($event, 'storage.') === 0:
$channels[] = 'files';
$channels[] = 'files.' . $payload->getId();
$roles = $payload->getAttribute('$permissions.read');
break;
case strpos($event, 'functions.executions.') === 0:
if (!empty($payload->getAttribute('$permissions.read'))) {
$channels[] = 'executions';
$channels[] = 'executions.' . $payload->getId();
$channels[] = 'functions.' . $payload->getAttribute('functionId');
$roles = $payload->getAttribute('$permissions.read');
}
break;
}
return [
'channels' => $channels,
'roles' => $roles,
'permissionsChanged' => $permissionsChanged
];
}
}

View file

@ -26,4 +26,4 @@ abstract class Worker
{
$this->shutdown();
}
}
}

View file

@ -16,6 +16,13 @@ class Execution extends Model
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('$permissions', [
'type' => Response::MODEL_PERMISSIONS,
'description' => 'Execution permissions.',
'default' => new \stdClass,
'example' => new \stdClass,
'array' => false,
])
->addRule('functionId', [
'type' => self::TYPE_STRING,
'description' => 'Function ID.',

34
tests/benchmarks/http.js Normal file
View file

@ -0,0 +1,34 @@
import http from 'k6/http';
import { check } from 'k6';
import { Counter } from 'k6/metrics';
// A simple counter for http requests
export const requests = new Counter('http_reqs');
// you can specify stages of your test (ramp up/down patterns) through the options object
// target is the number of VUs you are aiming for
export const options = {
stages: [
{ target: 50, duration: '1m' },
// { target: 15, duration: '1m' },
// { target: 0, duration: '1m' },
],
thresholds: {
requests: ['count < 100'],
},
};
export default function () {
const config = {
headers: {
'X-Appwrite-Key': '24356eb021863f81eb7dd77c7750304d0464e141cad6e9a8befa1f7d2b066fde190df3dab1e8d2639dbb82ee848da30501424923f4cd80d887ee40ad77ded62763ee489448523f6e39667f290f9a54b2ab8fad131a0bc985e6c0f760015f7f3411e40626c75646bb19d2bb2f7bf2f63130918220a206758cbc48845fd725a695',
'X-Appwrite-Project': '60479fe35d95d'
}}
const resDb = http.get('http://localhost:9501/', config);
check(resDb, {
'status is 200': (r) => r.status === 200,
});
}

59
tests/benchmarks/ws.js Normal file
View file

@ -0,0 +1,59 @@
// k6 run tests/benchmarks/ws.js
import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js';
import ws from 'k6/ws';
import { check } from 'k6';
export let options = {
stages: [
{
duration: '10s',
target: 500
},
{
duration: '1m',
target: 500
},
],
}
export default function () {
// const url = new URL('wss://appwrite-realtime.monitor-api.com/v1/realtime');
// url.searchParams.append('project', '604249e6b1a9f');
const url = new URL('ws://localhost/v1/realtime');
url.searchParams.append('project', '612625394933c');
url.searchParams.append('channels[]', 'files');
const res = ws.connect(url.toString(), function (socket) {
let connection = false;
let checked = false;
let payload = null;
socket.on('open', () => {
connection = true;
});
socket.on('message', (data) => {
payload = data;
checked = true;
});
socket.setTimeout(function () {
check(payload, {
'connection opened': (r) => connection,
'message received': (r) => checked,
'channels are right': (r) => r === JSON.stringify({
"type": "connected",
"data": {
"channels": [
"files"
],
"user": null
}
})
})
socket.close();
}, 5000);
});
check(res, { 'status is 101': (r) => r && r.status === 101 });
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
<?php
namespace Tests\E2E\Services\Realtime;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\SideClient;
class RealtimeCustomClientTest extends Scope
{
use RealtimeBase;
use ProjectCustom;
use SideClient;
}

View file

@ -196,4 +196,48 @@ class AuthTest extends TestCase
$this->assertEquals(false, Auth::isAppUser(['role:'.Auth::USER_ROLE_OWNER => true, 'role:'.Auth::USER_ROLE_GUEST => true]));
$this->assertEquals(false, Auth::isAppUser(['role:'.Auth::USER_ROLE_OWNER => true, 'role:'.Auth::USER_ROLE_ADMIN => true, 'role:'.Auth::USER_ROLE_DEVELOPER => true]));
}
public function testGuestRoles()
{
$user = new Document([
'$id' => ''
]);
$roles = Auth::getRoles($user);
$this->assertCount(1, $roles);
$this->assertContains('role:guest', $roles);
}
public function testUserRoles()
{
$user = new Document([
'$id' => '123',
'memberships' => [
[
'teamId' => 'abc',
'roles' => [
'administrator',
'moderator'
]
],
[
'teamId' => 'def',
'roles' => [
'guest'
]
]
]
]);
$roles = Auth::getRoles($user);
$this->assertCount(7, $roles);
$this->assertContains('role:member', $roles);
$this->assertContains('user:123', $roles);
$this->assertContains('team:abc', $roles);
$this->assertContains('team:abc/administrator', $roles);
$this->assertContains('team:abc/moderator', $roles);
$this->assertContains('team:def', $roles);
$this->assertContains('team:def/guest', $roles);
}
}

View file

@ -0,0 +1,306 @@
<?php
namespace Appwrite\Tests;
use Appwrite\Auth\Auth;
use Appwrite\Database\Document;
use Appwrite\Messaging\Adapter\Realtime;
use PHPUnit\Framework\TestCase;
class MessagingChannelsTest extends TestCase
{
/**
* Configures how many Connections the Test should Mock.
*/
public $connectionsPerChannel = 10;
public Realtime $realtime;
public $connectionsCount = 0;
public $connectionsAuthenticated = 0;
public $connectionsGuest = 0;
public $connectionsTotal = 0;
public $allChannels = [
'files',
'files.1',
'collections',
'collections.1',
'collections.1.documents',
'documents',
'documents.1',
'executions',
'executions.1',
'functions.1',
];
public function setUp(): void
{
/**
* Setup global Counts
*/
$this->connectionsAuthenticated = count($this->allChannels) * $this->connectionsPerChannel;
$this->connectionsGuest = count($this->allChannels) * $this->connectionsPerChannel;
$this->connectionsTotal = $this->connectionsAuthenticated + $this->connectionsGuest;
$this->realtime = new Realtime();
/**
* Add Authenticated Clients
*/
for ($i = 0; $i < $this->connectionsPerChannel; $i++) {
foreach ($this->allChannels as $index => $channel) {
$user = new Document([
'$id' => 'user' . $this->connectionsCount,
'memberships' => [
[
'teamId' => 'team' . $i,
'roles' => [
empty($index % 2) ? 'admin' : 'member'
]
]
]
]);
$roles = Auth::getRoles($user);
$parsedChannels = Realtime::convertChannels([0 => $channel], $user->getId());
$this->realtime->subscribe(
'1',
$this->connectionsCount,
$roles,
$parsedChannels
);
$this->connectionsCount++;
}
}
/**
* Add Guest Clients
*/
for ($i = 0; $i < $this->connectionsPerChannel; $i++) {
foreach ($this->allChannels as $index => $channel) {
$user = new Document([
'$id' => ''
]);
$roles = Auth::getRoles($user);
$parsedChannels = Realtime::convertChannels([0 => $channel], $user->getId());
$this->realtime->subscribe(
'1',
$this->connectionsCount,
$roles,
$parsedChannels
);
$this->connectionsCount++;
}
}
}
public function tearDown(): void
{
unset($this->realtime);
$this->connectionsCount = 0;
}
public function testSubscriptions()
{
/**
* Check for 1 project.
*/
$this->assertCount(1, $this->realtime->subscriptions);
/**
* Check for correct amount of subscriptions:
* - XXX users
* - XXX teams
* - XXX team roles (2 roles per team)
* - 1 role:guest
* - 1 role:member
*/
$this->assertCount(($this->connectionsAuthenticated + (3 * $this->connectionsPerChannel) + 2), $this->realtime->subscriptions['1']);
/**
* Check for connections
* - Authenticated
* - Guests
*/
$this->assertCount($this->connectionsTotal, $this->realtime->connections);
$this->realtime->unsubscribe(-1);
$this->assertCount($this->connectionsTotal, $this->realtime->connections);
$this->assertCount(($this->connectionsAuthenticated + (3 * $this->connectionsPerChannel) + 2), $this->realtime->subscriptions['1']);
for ($i = 0; $i < $this->connectionsCount; $i++) {
$this->realtime->unsubscribe($i);
$this->assertCount(($this->connectionsCount - $i - 1), $this->realtime->connections);
}
$this->assertEmpty($this->realtime->connections);
$this->assertEmpty($this->realtime->subscriptions);
}
/**
* Tests Wildcard (*) Permissions on every channel.
*/
public function testWildcardPermission()
{
foreach ($this->allChannels as $index => $channel) {
$event = [
'project' => '1',
'roles' => ['*'],
'data' => [
'channels' => [
0 => $channel,
]
]
];
$receivers = $this->realtime->getSubscribers($event);
/**
* Every Client subscribed to the Wildcard should receive this event.
*/
$this->assertCount($this->connectionsTotal / count($this->allChannels), $receivers, $channel);
foreach ($receivers as $receiver) {
/**
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiver);
}
}
}
public function testRolePermissions()
{
$roles = ['role:guest', 'role:member'];
foreach ($this->allChannels as $index => $channel) {
foreach ($roles as $role) {
$permissions = [$role];
$event = [
'project' => '1',
'roles' => $permissions,
'data' => [
'channels' => [
0 => $channel,
]
]
];
$receivers = $this->realtime->getSubscribers($event);
/**
* Every Role subscribed to a Channel should receive this event.
*/
$this->assertCount($this->connectionsPerChannel, $receivers, $channel);
foreach ($receivers as $receiver) {
/**
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiver);
}
}
}
}
public function testUserPermissions()
{
foreach ($this->allChannels as $index => $channel) {
$permissions = [];
for ($i = 0; $i < $this->connectionsPerChannel; $i++) {
$permissions[] = 'user:user' . (!empty($i) ? $i : '') . $index;
}
$event = [
'project' => '1',
'roles' => $permissions,
'data' => [
'channels' => [
0 => $channel,
]
]
];
$receivers = $this->realtime->getSubscribers($event);
/**
* Every Client subscribed to a Channel should receive this event.
*/
$this->assertCount($this->connectionsAuthenticated / count($this->allChannels), $receivers, $channel);
foreach ($receivers as $receiver) {
/**
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiver);
}
}
}
public function testTeamPermissions()
{
foreach ($this->allChannels as $index => $channel) {
$permissions = [];
for ($i = 0; $i < $this->connectionsPerChannel; $i++) {
$permissions[] = 'team:team' . $i;
}
$event = [
'project' => '1',
'roles' => $permissions,
'data' => [
'channels' => [
0 => $channel,
]
]
];
$receivers = $this->realtime->getSubscribers($event);
/**
* Every Team Member should receive this event.
*/
$this->assertCount($this->connectionsAuthenticated / count($this->allChannels), $receivers, $channel);
foreach ($receivers as $receiver) {
/**
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiver);
}
$permissions = ['team:team' . $index . '/' . (empty($index % 2) ? 'admin' : 'member')];
$event = [
'project' => '1',
'roles' => $permissions,
'data' => [
'channels' => [
0 => $channel,
]
]
];
$receivers = $this->realtime->getSubscribers($event);
/**
* Only 1 Team Member of a role should have access to a specific channel.
*/
$this->assertCount(1, $receivers, $channel);
foreach ($receivers as $receiver) {
/**
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiver);
}
}
}
}

View file

@ -0,0 +1,128 @@
<?php
namespace Appwrite\Tests;
use Appwrite\Messaging\Adapter\Realtime;
use PHPUnit\Framework\TestCase;
class MessagingGuestTest extends TestCase
{
public function testGuest()
{
$realtime = new Realtime();
$realtime->subscribe(
'1',
1,
['role:guest'],
['files' => 0, 'documents' => 0, 'documents.789' => 0, 'account.123' => 0]
);
$event = [
'project' => '1',
'roles' => ['*'],
'data' => [
'channels' => [
0 => 'documents',
1 => 'documents',
]
]
];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['role:guest'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['role:member'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['user:123'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:abc'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:abc/administrator'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:abc/god'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:def'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:def/guest'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['user:456'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:def/member'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['*'];
$event['data']['channels'] = ['documents.123'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['data']['channels'] = ['documents.789'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['project'] = '2';
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$realtime->unsubscribe(2);
$this->assertCount(1, $realtime->connections);
$this->assertCount(1, $realtime->subscriptions['1']);
$realtime->unsubscribe(1);
$this->assertEmpty($realtime->connections);
$this->assertEmpty($realtime->subscriptions);
}
}

View file

@ -0,0 +1,198 @@
<?php
namespace Appwrite\Tests;
use Appwrite\Database\Document;
use Appwrite\Messaging\Adapter\Realtime;
use PHPUnit\Framework\TestCase;
class MessagingTest extends TestCase
{
public function setUp(): void
{
}
public function tearDown(): void
{
}
public function testUser()
{
$realtime = new Realtime();
$realtime->subscribe(
'1',
1,
['user:123', 'role:member', 'team:abc', 'team:abc/administrator', 'team:abc/moderator', 'team:def', 'team:def/guest'],
['files' => 0, 'documents' => 0, 'documents.789' => 0, 'account.123' => 0]
);
$event = [
'project' => '1',
'roles' => ['*'],
'data' => [
'channels' => [
0 => 'account.123',
]
]
];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['role:member'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['user:123'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['team:abc'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['team:abc/administrator'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['team:abc/moderator'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['team:def'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['team:def/guest'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['roles'] = ['user:456'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['team:def/member'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['roles'] = ['*'];
$event['data']['channels'] = ['documents.123'];
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$event['data']['channels'] = ['documents.789'];
$receivers = $realtime->getSubscribers($event);
$this->assertCount(1, $receivers);
$this->assertEquals(1, $receivers[0]);
$event['project'] = '2';
$receivers = $realtime->getSubscribers($event);
$this->assertEmpty($receivers);
$realtime->unsubscribe(2);
$this->assertCount(1, $realtime->connections);
$this->assertCount(7, $realtime->subscriptions['1']);
$realtime->unsubscribe(1);
$this->assertEmpty($realtime->connections);
$this->assertEmpty($realtime->subscriptions);
}
public function testConvertChannelsGuest()
{
$user = new Document([
'$id' => ''
]);
$channels = [
0 => 'files',
1 => 'documents',
2 => 'documents.789',
3 => 'account',
4 => 'account.456'
];
$channels = Realtime::convertChannels($channels, $user->getId());
$this->assertCount(4, $channels);
$this->assertArrayHasKey('files', $channels);
$this->assertArrayHasKey('documents', $channels);
$this->assertArrayHasKey('documents.789', $channels);
$this->assertArrayHasKey('account', $channels);
$this->assertArrayNotHasKey('account.456', $channels);
}
public function testConvertChannelsUser()
{
$user = new Document([
'$id' => '123',
'memberships' => [
[
'teamId' => 'abc',
'roles' => [
'administrator',
'moderator'
]
],
[
'teamId' => 'def',
'roles' => [
'guest'
]
]
]
]);
$channels = [
0 => 'files',
1 => 'documents',
2 => 'documents.789',
3 => 'account',
4 => 'account.456'
];
$channels = Realtime::convertChannels($channels, $user->getId());
$this->assertCount(5, $channels);
$this->assertArrayHasKey('files', $channels);
$this->assertArrayHasKey('documents', $channels);
$this->assertArrayHasKey('documents.789', $channels);
$this->assertArrayHasKey('account.123', $channels);
$this->assertArrayHasKey('account', $channels);
$this->assertArrayNotHasKey('account.456', $channels);
}
}